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

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

140: if (error instanceof Error) { 141: const errnoCode = getErrnoCode(error) 142: if (typeof errnoCode === ‘string’) { 143: return Error:${errnoCode} 144: } 145: if (error.name && error.name !== ‘Error’ && error.name.length > 3) { 146: return error.name.slice(0, 60) 147: } 148: return ‘Error’ 149: } 150: return ‘UnknownError’ 151: } 152: function ruleSourceToOTelSource( 153: ruleSource: string, 154: behavior: ‘allow’ | ‘deny’, 155: ): string { 156: switch (ruleSource) { 157: case ‘session’: 158: return behavior === ‘allow’ ? ‘user_temporary’ : ‘user_reject’ 159: case ‘localSettings’: 160: case ‘userSettings’: 161: return behavior === ‘allow’ ? ‘user_permanent’ : ‘user_reject’ 162: default: 163: return ‘config’ 164: } 165: } 166: function decisionReasonToOTelSource( 167: reason: PermissionDecisionReason | undefined, 168: behavior: ‘allow’ | ‘deny’, 169: ): string { 170: if (!reason) { 171: return ‘config’ 172: } 173: switch (reason.type) { 174: case ‘permissionPromptTool’: { 175: const toolResult = reason.toolResult as 176: | { decisionClassification?: string } 177: | undefined 178: const classified = toolResult?.decisionClassification 179: if ( 180: classified === ‘user_temporary’ || 181: classified === ‘user_permanent’ || 182: classified === ‘user_reject’ 183: ) { 184: return classified 185: } 186: return behavior === ‘allow’ ? ‘user_temporary’ : ‘user_reject’ 187: } 188: case ‘rule’: 189: return ruleSourceToOTelSource(reason.rule.source, behavior) 190: case ‘hook’: 191: return ‘hook’ 192: case ‘mode’: 193: case ‘classifier’: 194: case ‘subcommandResults’: 195: case ‘asyncAgent’: 196: case ‘sandboxOverride’: 197: case ‘workingDir’: 198: case ‘safetyCheck’: 199: case ‘other’: 200: return ‘config’ 201: default: { 202: const _exhaustive: never = reason 203: return ‘config’ 204: } 205: } 206: } 207: function getNextImagePasteId(messages: Message[]): number { 208: let maxId = 0 209: for (const message of messages) { 210: if (message.type === ‘user’ && message.imagePasteIds) { 211: for (const id of message.imagePasteIds) { 212: if (id > maxId) maxId = id 213: } 214: } 215: } 216: return maxId + 1 217: } 218: export type MessageUpdateLazy = { 219: message: M 220: contextModifier?: { 221: toolUseID: string 222: modifyContext: (context: ToolUseContext) => ToolUseContext 223: } 224: } 225: export type McpServerType = 226: | 'stdio' 227: | 'sse' 228: | 'http' 229: | 'ws' 230: | 'sdk' 231: | 'sse-ide' 232: | 'ws-ide' 233: | 'claudeai-proxy' 234: | undefined 235: function findMcpServerConnection( 236: toolName: string, 237: mcpClients: MCPServerConnection[], 238: ): MCPServerConnection | undefined { 239: if (!toolName.startsWith('mcp__')) { 240: return undefined 241: } 242: const mcpInfo = mcpInfoFromString(toolName) 243: if (!mcpInfo) { 244: return undefined 245: } 246: return mcpClients.find( 247: client => normalizeNameForMCP(client.name) === mcpInfo.serverName, 248: ) 249: } 250: function getMcpServerType( 251: toolName: string, 252: mcpClients: MCPServerConnection[], 253: ): McpServerType { 254: const serverConnection = findMcpServerConnection(toolName, mcpClients) 255: if (serverConnection?.type === 'connected') { 256: return serverConnection.config.type ?? 'stdio' 257: } 258: return undefined 259: } 260: function getMcpServerBaseUrlFromToolName( 261: toolName: string, 262: mcpClients: MCPServerConnection[], 263: ): string | undefined { 264: const serverConnection = findMcpServerConnection(toolName, mcpClients) 265: if (serverConnection?.type !== 'connected') { 266: return undefined 267: } 268: return getLoggingSafeMcpBaseUrl(serverConnection.config) 269: } 270: export async function* runToolUse( 271: toolUse: ToolUseBlock, 272: assistantMessage: AssistantMessage, 273: canUseTool: CanUseToolFn, 274: toolUseContext: ToolUseContext, 275: ): AsyncGenerator<MessageUpdateLazy, void> { 276: const toolName = toolUse.name 277: let tool = findToolByName(toolUseContext.options.tools, toolName) 278: if (!tool) { 279: const fallbackTool = findToolByName(getAllBaseTools(), toolName) 280: if (fallbackTool && fallbackTool.aliases?.includes(toolName)) { 281: tool = fallbackTool 282: } 283: } 284: const messageId = assistantMessage.message.id 285: const requestId = assistantMessage.requestId 286: const mcpServerType = getMcpServerType( 287: toolName, 288: toolUseContext.options.mcpClients, 289: ) 290: const mcpServerBaseUrl = getMcpServerBaseUrlFromToolName( 291: toolName, 292: toolUseContext.options.mcpClients, 293: ) 294: if (!tool) { 295: const sanitizedToolName = sanitizeToolNameForAnalytics(toolName) 296: logForDebugging(`Unknown tool ${toolName}: ${toolUse.id}`) 297: logEvent('tengu_tool_use_error', { 298: error: 299: `No such tool available: ${sanitizedToolName}` as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 300: toolName: sanitizedToolName, 301: toolUseID: 302: toolUse.id as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 303: isMcp: toolName.startsWith('mcp__'), 304: queryChainId: toolUseContext.queryTracking 305: ?.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 306: queryDepth: toolUseContext.queryTracking?.depth, 307: ...(mcpServerType && { 308: mcpServerType: 309: mcpServerType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 310: }), 311: ...(mcpServerBaseUrl && { 312: mcpServerBaseUrl: 313: mcpServerBaseUrl as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 314: }), 315: ...(requestId && { 316: requestId: 317: requestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 318: }), 319: ...mcpToolDetailsForAnalytics(toolName, mcpServerType, mcpServerBaseUrl), 320: }) 321: yield { 322: message: createUserMessage({ 323: content: [ 324: { 325: type: 'tool_result', 326: content: `Error: No such tool available: ${toolName}`, 327: is_error: true, 328: tool_use_id: toolUse.id, 329: }, 330: ], 331: toolUseResult: `Error: No such tool available: ${toolName}`, 332: sourceToolAssistantUUID: assistantMessage.uuid, 333: }), 334: } 335: return 336: } 337: const toolInput = toolUse.input as { [key: string]: string } 338: try { 339: if (toolUseContext.abortController.signal.aborted) { 340: logEvent('tengu_tool_use_cancelled', { 341: toolName: sanitizeToolNameForAnalytics(tool.name), 342: toolUseID: 343: toolUse.id as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 344: isMcp: tool.isMcp ?? false, 345: queryChainId: toolUseContext.queryTracking 346: ?.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 347: queryDepth: toolUseContext.queryTracking?.depth, 348: ...(mcpServerType && { 349: mcpServerType: 350: mcpServerType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 351: }), 352: ...(mcpServerBaseUrl && { 353: mcpServerBaseUrl: 354: mcpServerBaseUrl as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 355: }), 356: ...(requestId && { 357: requestId: 358: requestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 359: }), 360: ...mcpToolDetailsForAnalytics( 361: tool.name, 362: mcpServerType, 363: mcpServerBaseUrl, 364: ), 365: }) 366: const content = createToolResultStopMessage(toolUse.id) 367: content.content = withMemoryCorrectionHint(CANCEL_MESSAGE) 368: yield { 369: message: createUserMessage({ 370: content: [content], 371: toolUseResult: CANCEL_MESSAGE, 372: sourceToolAssistantUUID: assistantMessage.uuid, 373: }), 374: } 375: return 376: } 377: for await (const update of streamedCheckPermissionsAndCallTool( 378: tool, 379: toolUse.id, 380: toolInput, 381: toolUseContext, 382: canUseTool, 383: assistantMessage, 384: messageId, 385: requestId, 386: mcpServerType, 387: mcpServerBaseUrl, 388: )) { 389: yield update 390: } 391: } catch (error) { 392: logError(error) 393: const errorMessage = error instanceof Error ? error.message : String(error) 394: const toolInfo = tool ? ` (${tool.name})` : '' 395: const detailedError = `Error calling tool${toolInfo}: ${errorMessage}` 396: yield { 397: message: createUserMessage({ 398: content: [ 399: { 400: type: 'tool_result', 401: content: `${detailedError}`, 402: is_error: true, 403: tool_use_id: toolUse.id, 404: }, 405: ], 406: toolUseResult: detailedError, 407: sourceToolAssistantUUID: assistantMessage.uuid, 408: }), 409: } 410: } 411: } 412: function streamedCheckPermissionsAndCallTool( 413: tool: Tool, 414: toolUseID: string, 415: input: { [key: string]: boolean | string | number }, 416: toolUseContext: ToolUseContext, 417: canUseTool: CanUseToolFn, 418: assistantMessage: AssistantMessage, 419: messageId: string, 420: requestId: string | undefined, 421: mcpServerType: McpServerType, 422: mcpServerBaseUrl: ReturnType, 423: ): AsyncIterable { 424: const stream = new Stream() 425: checkPermissionsAndCallTool( 426: tool, 427: toolUseID, 428: input, 429: toolUseContext, 430: canUseTool, 431: assistantMessage, 432: messageId, 433: requestId, 434: mcpServerType, 435: mcpServerBaseUrl, 436: progress => { 437: logEvent('tengu_tool_use_progress', { 438: messageID: 439: messageId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 440: toolName: sanitizeToolNameForAnalytics(tool.name), 441: isMcp: tool.isMcp ?? false, 442: queryChainId: toolUseContext.queryTracking 443: ?.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 444: queryDepth: toolUseContext.queryTracking?.depth, 445: ...(mcpServerType && { 446: mcpServerType: 447: mcpServerType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 448: }), 449: ...(mcpServerBaseUrl && { 450: mcpServerBaseUrl: 451: mcpServerBaseUrl as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 452: }), 453: ...(requestId && { 454: requestId: 455: requestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 456: }), 457: ...mcpToolDetailsForAnalytics( 458: tool.name, 459: mcpServerType, 460: mcpServerBaseUrl, 461: ), 462: }) 463: stream.enqueue({ 464: message: createProgressMessage({ 465: toolUseID: progress.toolUseID, 466: parentToolUseID: toolUseID, 467: data: progress.data, 468: }), 469: }) 470: }, 471: ) 472: .then(results => { 473: for (const result of results) { 474: stream.enqueue(result) 475: } 476: }) 477: .catch(error => { 478: stream.error(error) 479: }) 480: .finally(() => { 481: stream.done() 482: }) 483: return stream 484: } 485: export function buildSchemaNotSentHint( 486: tool: Tool, 487: messages: Message[], 488: tools: readonly { name: string }[], 489: ): string | null { 490: if (!isToolSearchEnabledOptimistic()) return null 491: if (!isToolSearchToolAvailable(tools)) return null 492: if (!isDeferredTool(tool)) return null 493: const discovered = extractDiscoveredToolNames(messages) 494: if (discovered.has(tool.name)) return null 495: return ( 496: `\n\nThis tool's schema was not sent to the API — it was not in the discovered-tool set derived from message history. ` + 497: `Without the schema in your prompt, typed parameters (arrays, numbers, booleans) get emitted as strings and the client-side parser rejects them. ` + 498: `Load the tool first: call ${TOOL_SEARCH_TOOL_NAME} with query "select:${tool.name}", then retry this call.` 499: ) 500: } 501: async function checkPermissionsAndCallTool( 502: tool: Tool, 503: toolUseID: string, 504: input: { [key: string]: boolean | string | number }, 505: toolUseContext: ToolUseContext, 506: canUseTool: CanUseToolFn, 507: assistantMessage: AssistantMessage, 508: messageId: string, 509: requestId: string | undefined, 510: mcpServerType: McpServerType, 511: mcpServerBaseUrl: ReturnType, 512: onToolProgress: ( 513: progress: ToolProgress | ProgressMessage, 514: ) => void, 515: ): Promise<MessageUpdateLazy[]> { 516: const parsedInput = tool.inputSchema.safeParse(input) 517: if (!parsedInput.success) { 518: let errorContent = formatZodValidationError(tool.name, parsedInput.error) 519: const schemaHint = buildSchemaNotSentHint( 520: tool, 521: toolUseContext.messages, 522: toolUseContext.options.tools, 523: ) 524: if (schemaHint) { 525: logEvent('tengu_deferred_tool_schema_not_sent', { 526: toolName: sanitizeToolNameForAnalytics(tool.name), 527: isMcp: tool.isMcp ?? false, 528: }) 529: errorContent += schemaHint 530: } 531: logForDebugging( 532: `${tool.name} tool input error: ${errorContent.slice(0, 200)}`, 533: ) 534: logEvent('tengu_tool_use_error', { 535: error: 536: 'InputValidationError' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 537: errorDetails: errorContent.slice( 538: 0, 539: 2000, 540: ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 541: messageID: 542: messageId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 543: toolName: sanitizeToolNameForAnalytics(tool.name), 544: isMcp: tool.isMcp ?? false, 545: queryChainId: toolUseContext.queryTracking 546: ?.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 547: queryDepth: toolUseContext.queryTracking?.depth, 548: ...(mcpServerType && { 549: mcpServerType: 550: mcpServerType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 551: }), 552: ...(mcpServerBaseUrl && { 553: mcpServerBaseUrl: 554: mcpServerBaseUrl as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 555: }), 556: ...(requestId && { 557: requestId: 558: requestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 559: }), 560: ...mcpToolDetailsForAnalytics(tool.name, mcpServerType, mcpServerBaseUrl), 561: }) 562: return [ 563: { 564: message: createUserMessage({ 565: content: [ 566: { 567: type: 'tool_result', 568: content: `InputValidationError: ${errorContent}`, 569: is_error: true, 570: tool_use_id: toolUseID, 571: }, 572: ], 573: toolUseResult: `InputValidationError: ${parsedInput.error.message}`, 574: sourceToolAssistantUUID: assistantMessage.uuid, 575: }), 576: }, 577: ] 578: } 579: const isValidCall = await tool.validateInput?.( 580: parsedInput.data, 581: toolUseContext, 582: ) 583: if (isValidCall?.result === false) { 584: logForDebugging( 585: `${tool.name} tool validation error: ${isValidCall.message?.slice(0, 200)}`, 586: ) 587: logEvent('tengu_tool_use_error', { 588: messageID: 589: messageId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 590: toolName: sanitizeToolNameForAnalytics(tool.name), 591: error: 592: isValidCall.message as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 593: errorCode: isValidCall.errorCode, 594: isMcp: tool.isMcp ?? false, 595: queryChainId: toolUseContext.queryTracking 596: ?.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 597: queryDepth: toolUseContext.queryTracking?.depth, 598: ...(mcpServerType && { 599: mcpServerType: 600: mcpServerType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 601: }), 602: ...(mcpServerBaseUrl && { 603: mcpServerBaseUrl: 604: mcpServerBaseUrl as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 605: }), 606: ...(requestId && { 607: requestId: 608: requestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 609: }), 610: ...mcpToolDetailsForAnalytics(tool.name, mcpServerType, mcpServerBaseUrl), 611: }) 612: return [ 613: { 614: message: createUserMessage({ 615: content: [ 616: { 617: type: 'tool_result', 618: content: `${isValidCall.message}`, 619: is_error: true, 620: tool_use_id: toolUseID, 621: }, 622: ], 623: toolUseResult: `Error: ${isValidCall.message}`, 624: sourceToolAssistantUUID: assistantMessage.uuid, 625: }), 626: }, 627: ] 628: } 629: if ( 630: tool.name === BASH_TOOL_NAME && 631: parsedInput.data && 632: 'command' in parsedInput.data 633: ) { 634: const appState = toolUseContext.getAppState() 635: startSpeculativeClassifierCheck( 636: (parsedInput.data as BashToolInput).command, 637: appState.toolPermissionContext, 638: toolUseContext.abortController.signal, 639: toolUseContext.options.isNonInteractiveSession, 640: ) 641: } 642: const resultingMessages = [] 643: let processedInput = parsedInput.data 644: if ( 645: tool.name === BASH_TOOL_NAME && 646: processedInput && 647: typeof processedInput === 'object' && 648: '_simulatedSedEdit' in processedInput 649: ) { 650: const { _simulatedSedEdit: _, ...rest } = 651: processedInput as typeof processedInput & { 652: _simulatedSedEdit: unknown 653: } 654: processedInput = rest as typeof processedInput 655: } 656: let callInput = processedInput 657: const backfilledClone = 658: tool.backfillObservableInput && 659: typeof processedInput === 'object' && 660: processedInput !== null 661: ? ({ ...processedInput } as typeof processedInput) 662: : null 663: if (backfilledClone) { 664: tool.backfillObservableInput!(backfilledClone as Record<string, unknown>) 665: processedInput = backfilledClone 666: } 667: let shouldPreventContinuation = false 668: let stopReason: string | undefined 669: let hookPermissionResult: PermissionResult | undefined 670: const preToolHookInfos: StopHookInfo[] = [] 671: const preToolHookStart = Date.now() 672: for await (const result of runPreToolUseHooks( 673: toolUseContext, 674: tool, 675: processedInput, 676: toolUseID, 677: assistantMessage.message.id, 678: requestId, 679: mcpServerType, 680: mcpServerBaseUrl, 681: )) { 682: switch (result.type) { 683: case 'message': 684: if (result.message.message.type === 'progress') { 685: onToolProgress(result.message.message) 686: } else { 687: resultingMessages.push(result.message) 688: const att = result.message.message.attachment 689: if ( 690: att && 691: 'command' in att && 692: att.command !== undefined && 693: 'durationMs' in att && 694: att.durationMs !== undefined 695: ) { 696: preToolHookInfos.push({ 697: command: att.command, 698: durationMs: att.durationMs, 699: }) 700: } 701: } 702: break 703: case 'hookPermissionResult': 704: hookPermissionResult = result.hookPermissionResult 705: break 706: case 'hookUpdatedInput': 707: processedInput = result.updatedInput 708: break 709: case 'preventContinuation': 710: shouldPreventContinuation = result.shouldPreventContinuation 711: break 712: case 'stopReason': 713: stopReason = result.stopReason 714: break 715: case 'additionalContext': 716: resultingMessages.push(result.message) 717: break 718: case 'stop': 719: getStatsStore()?.observe( 720: 'pre_tool_hook_duration_ms', 721: Date.now() - preToolHookStart, 722: ) 723: resultingMessages.push({ 724: message: createUserMessage({ 725: content: [createToolResultStopMessage(toolUseID)], 726: toolUseResult: `Error: ${stopReason}`, 727: sourceToolAssistantUUID: assistantMessage.uuid, 728: }), 729: }) 730: return resultingMessages 731: } 732: } 733: const preToolHookDurationMs = Date.now() - preToolHookStart 734: getStatsStore()?.observe('pre_tool_hook_duration_ms', preToolHookDurationMs) 735: if (preToolHookDurationMs >= SLOW_PHASE_LOG_THRESHOLD_MS) { 736: logForDebugging( 737: `Slow PreToolUse hooks: ${preToolHookDurationMs}ms for ${tool.name} (${preToolHookInfos.length} hooks)`, 738: { level: 'info' }, 739: ) 740: } 741: if (process.env.USER_TYPE === 'ant' && preToolHookInfos.length > 0) { 742: if (preToolHookDurationMs > HOOK_TIMING_DISPLAY_THRESHOLD_MS) { 743: resultingMessages.push({ 744: message: createStopHookSummaryMessage( 745: preToolHookInfos.length, 746: preToolHookInfos, 747: [], 748: false, 749: undefined, 750: false, 751: 'suggestion', 752: undefined, 753: 'PreToolUse', 754: preToolHookDurationMs, 755: ), 756: }) 757: } 758: } 759: const toolAttributes: Record<string, string | number | boolean> = {} 760: if (processedInput && typeof processedInput === 'object') { 761: if (tool.name === FILE_READ_TOOL_NAME && 'file_path' in processedInput) { 762: toolAttributes.file_path = String(processedInput.file_path) 763: } else if ( 764: (tool.name === FILE_EDIT_TOOL_NAME || 765: tool.name === FILE_WRITE_TOOL_NAME) && 766: 'file_path' in processedInput 767: ) { 768: toolAttributes.file_path = String(processedInput.file_path) 769: } else if (tool.name === BASH_TOOL_NAME && 'command' in processedInput) { 770: const bashInput = processedInput as BashToolInput 771: toolAttributes.full_command = bashInput.command 772: } 773: } 774: startToolSpan( 775: tool.name, 776: toolAttributes, 777: isBetaTracingEnabled() ? jsonStringify(processedInput) : undefined, 778: ) 779: startToolBlockedOnUserSpan() 780: const permissionMode = toolUseContext.getAppState().toolPermissionContext.mode 781: const permissionStart = Date.now() 782: const resolved = await resolveHookPermissionDecision( 783: hookPermissionResult, 784: tool, 785: processedInput, 786: toolUseContext, 787: canUseTool, 788: assistantMessage, 789: toolUseID, 790: ) 791: const permissionDecision = resolved.decision 792: processedInput = resolved.input 793: const permissionDurationMs = Date.now() - permissionStart 794: if ( 795: permissionDurationMs >= SLOW_PHASE_LOG_THRESHOLD_MS && 796: permissionMode === 'auto' 797: ) { 798: logForDebugging( 799: `Slow permission decision: ${permissionDurationMs}ms for ${tool.name} ` + 800: `(mode=${permissionMode}, behavior=${permissionDecision.behavior})`, 801: { level: 'info' }, 802: ) 803: } 804: if ( 805: permissionDecision.behavior !== 'ask' && 806: !toolUseContext.toolDecisions?.has(toolUseID) 807: ) { 808: const decision = 809: permissionDecision.behavior === 'allow' ? 'accept' : 'reject' 810: const source = decisionReasonToOTelSource( 811: permissionDecision.decisionReason, 812: permissionDecision.behavior, 813: ) 814: void logOTelEvent('tool_decision', { 815: decision, 816: source, 817: tool_name: sanitizeToolNameForAnalytics(tool.name), 818: }) 819: if (isCodeEditingTool(tool.name)) { 820: void buildCodeEditToolAttributes( 821: tool, 822: processedInput, 823: decision, 824: source, 825: ).then(attributes => getCodeEditToolDecisionCounter()?.add(1, attributes)) 826: } 827: } 828: if ( 829: permissionDecision.decisionReason?.type === 'hook' && 830: permissionDecision.decisionReason.hookName === 'PermissionRequest' && 831: permissionDecision.behavior !== 'ask' 832: ) { 833: resultingMessages.push({ 834: message: createAttachmentMessage({ 835: type: 'hook_permission_decision', 836: decision: permissionDecision.behavior, 837: toolUseID, 838: hookEvent: 'PermissionRequest', 839: }), 840: }) 841: } 842: if (permissionDecision.behavior !== 'allow') { 843: logForDebugging(`${tool.name} tool permission denied`) 844: const decisionInfo = toolUseContext.toolDecisions?.get(toolUseID) 845: endToolBlockedOnUserSpan('reject', decisionInfo?.source || 'unknown') 846: endToolSpan() 847: logEvent('tengu_tool_use_can_use_tool_rejected', { 848: messageID: 849: messageId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 850: toolName: sanitizeToolNameForAnalytics(tool.name), 851: queryChainId: toolUseContext.queryTracking 852: ?.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 853: queryDepth: toolUseContext.queryTracking?.depth, 854: ...(mcpServerType && { 855: mcpServerType: 856: mcpServerType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 857: }), 858: ...(mcpServerBaseUrl && { 859: mcpServerBaseUrl: 860: mcpServerBaseUrl as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 861: }), 862: ...(requestId && { 863: requestId: 864: requestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 865: }), 866: ...mcpToolDetailsForAnalytics(tool.name, mcpServerType, mcpServerBaseUrl), 867: }) 868: let errorMessage = permissionDecision.message 869: if (shouldPreventContinuation && !errorMessage) { 870: errorMessage = `Execution stopped by PreToolUse hook${stopReason ? `: ${stopReason}` : ''}` 871: } 872: const messageContent: ContentBlockParam[] = [ 873: { 874: type: 'tool_result', 875: content: errorMessage, 876: is_error: true, 877: tool_use_id: toolUseID, 878: }, 879: ] 880: const rejectContentBlocks = 881: permissionDecision.behavior === 'ask' 882: ? permissionDecision.contentBlocks 883: : undefined 884: if (rejectContentBlocks?.length) { 885: messageContent.push(...rejectContentBlocks) 886: } 887: let rejectImageIds: number[] | undefined 888: if (rejectContentBlocks?.length) { 889: const imageCount = count( 890: rejectContentBlocks, 891: (b: ContentBlockParam) => b.type === 'image', 892: ) 893: if (imageCount > 0) { 894: const startId = getNextImagePasteId(toolUseContext.messages) 895: rejectImageIds = Array.from( 896: { length: imageCount }, 897: (_, i) => startId + i, 898: ) 899: } 900: } 901: resultingMessages.push({ 902: message: createUserMessage({ 903: content: messageContent, 904: imagePasteIds: rejectImageIds, 905: toolUseResult: `Error: ${errorMessage}`, 906: sourceToolAssistantUUID: assistantMessage.uuid, 907: }), 908: }) 909: if ( 910: feature('TRANSCRIPT_CLASSIFIER') && 911: permissionDecision.decisionReason?.type === 'classifier' && 912: permissionDecision.decisionReason.classifier === 'auto-mode' 913: ) { 914: let hookSaysRetry = false 915: for await (const result of executePermissionDeniedHooks( 916: tool.name, 917: toolUseID, 918: processedInput, 919: permissionDecision.decisionReason.reason ?? 'Permission denied', 920: toolUseContext, 921: permissionMode, 922: toolUseContext.abortController.signal, 923: )) { 924: if (result.retry) hookSaysRetry = true 925: } 926: if (hookSaysRetry) { 927: resultingMessages.push({ 928: message: createUserMessage({ 929: content: 930: 'The PermissionDenied hook indicated this command is now approved. You may retry it if you would like.', 931: isMeta: true, 932: }), 933: }) 934: } 935: } 936: return resultingMessages 937: } 938: logEvent('tengu_tool_use_can_use_tool_allowed', { 939: messageID: 940: messageId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 941: toolName: sanitizeToolNameForAnalytics(tool.name), 942: queryChainId: toolUseContext.queryTracking 943: ?.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 944: queryDepth: toolUseContext.queryTracking?.depth, 945: ...(mcpServerType && { 946: mcpServerType: 947: mcpServerType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 948: }), 949: ...(mcpServerBaseUrl && { 950: mcpServerBaseUrl: 951: mcpServerBaseUrl as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 952: }), 953: ...(requestId && { 954: requestId: 955: requestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 956: }), 957: ...mcpToolDetailsForAnalytics(tool.name, mcpServerType, mcpServerBaseUrl), 958: }) 959: if (permissionDecision.updatedInput !== undefined) { 960: processedInput = permissionDecision.updatedInput 961: } 962: const telemetryToolInput = extractToolInputForTelemetry(processedInput) 963: let toolParameters: Record<string, unknown> = {} 964: if (isToolDetailsLoggingEnabled()) { 965: if (tool.name === BASH_TOOL_NAME && 'command' in processedInput) { 966: const bashInput = processedInput as BashToolInput 967: const commandParts = bashInput.command.trim().split(/\s+/) 968: const bashCommand = commandParts[0] || '' 969: toolParameters = { 970: bash_command: bashCommand, 971: full_command: bashInput.command, 972: ...(bashInput.timeout !== undefined && { 973: timeout: bashInput.timeout, 974: }), 975: ...(bashInput.description !== undefined && { 976: description: bashInput.description, 977: }), 978: ...('dangerouslyDisableSandbox' in bashInput && { 979: dangerouslyDisableSandbox: bashInput.dangerouslyDisableSandbox, 980: }), 981: } 982: } 983: const mcpDetails = extractMcpToolDetails(tool.name) 984: if (mcpDetails) { 985: toolParameters.mcp_server_name = mcpDetails.serverName 986: toolParameters.mcp_tool_name = mcpDetails.mcpToolName 987: } 988: const skillName = extractSkillName(tool.name, processedInput) 989: if (skillName) { 990: toolParameters.skill_name = skillName 991: } 992: } 993: const decisionInfo = toolUseContext.toolDecisions?.get(toolUseID) 994: endToolBlockedOnUserSpan( 995: decisionInfo?.decision || 'unknown', 996: decisionInfo?.source || 'unknown', 997: ) 998: startToolExecutionSpan() 999: const startTime = Date.now() 1000: startSessionActivity('tool_exec') 1001: if ( 1002: backfilledClone && 1003: processedInput !== callInput && 1004: typeof processedInput === 'object' && 1005: processedInput !== null && 1006: 'file_path' in processedInput && 1007: 'file_path' in (callInput as Record<string, unknown>) && 1008: (processedInput as Record<string, unknown>).file_path === 1009: (backfilledClone as Record<string, unknown>).file_path 1010: ) { 1011: callInput = { 1012: ...processedInput, 1013: file_path: (callInput as Record<string, unknown>).file_path, 1014: } as typeof processedInput 1015: } else if (processedInput !== backfilledClone) { 1016: callInput = processedInput 1017: } 1018: try { 1019: const result = await tool.call( 1020: callInput, 1021: { 1022: ...toolUseContext, 1023: toolUseId: toolUseID, 1024: userModified: permissionDecision.userModified ?? false, 1025: }, 1026: canUseTool, 1027: assistantMessage, 1028: progress => { 1029: onToolProgress({ 1030: toolUseID: progress.toolUseID, 1031: data: progress.data, 1032: }) 1033: }, 1034: ) 1035: const durationMs = Date.now() - startTime 1036: addToToolDuration(durationMs) 1037: if (result.data && typeof result.data === 'object') { 1038: const contentAttributes: Record<string, string | number | boolean> = {} 1039: if (tool.name === FILE_READ_TOOL_NAME && 'content' in result.data) { 1040: if ('file_path' in processedInput) { 1041: contentAttributes.file_path = String(processedInput.file_path) 1042: } 1043: contentAttributes.content = String(result.data.content) 1044: } 1045: if ( 1046: (tool.name === FILE_EDIT_TOOL_NAME || 1047: tool.name === FILE_WRITE_TOOL_NAME) && 1048: 'file_path' in processedInput 1049: ) { 1050: contentAttributes.file_path = String(processedInput.file_path) 1051: if (tool.name === FILE_EDIT_TOOL_NAME && 'diff' in result.data) { 1052: contentAttributes.diff = String(result.data.diff) 1053: } 1054: if (tool.name === FILE_WRITE_TOOL_NAME && 'content' in processedInput) { 1055: contentAttributes.content = String(processedInput.content) 1056: } 1057: } 1058: if (tool.name === BASH_TOOL_NAME && 'command' in processedInput) { 1059: const bashInput = processedInput as BashToolInput 1060: contentAttributes.bash_command = bashInput.command 1061: if ('output' in result.data) { 1062: contentAttributes.output = String(result.data.output) 1063: } 1064: } 1065: if (Object.keys(contentAttributes).length > 0) { 1066: addToolContentEvent('tool.output', contentAttributes) 1067: } 1068: } 1069: if (typeof result === 'object' && 'structured_output' in result) { 1070: resultingMessages.push({ 1071: message: createAttachmentMessage({ 1072: type: 'structured_output', 1073: data: result.structured_output, 1074: }), 1075: }) 1076: } 1077: endToolExecutionSpan({ success: true }) 1078: const toolResultStr = 1079: result.data && typeof result.data === 'object' 1080: ? jsonStringify(result.data) 1081: : String(result.data ?? '') 1082: endToolSpan(toolResultStr) 1083: // Map the tool result to API format once and cache it. This block is reused 1084: // by addToolResult (skipping the remap) and measured here for analytics. 1085: const mappedToolResultBlock = tool.mapToolResultToToolResultBlockParam( 1086: result.data, 1087: toolUseID, 1088: ) 1089: const mappedContent = mappedToolResultBlock.content 1090: const toolResultSizeBytes = !mappedContent 1091: ? 0 1092: : typeof mappedContent === 'string' 1093: ? mappedContent.length 1094: : jsonStringify(mappedContent).length 1095: let fileExtension: ReturnType 1096: if (processedInput && typeof processedInput === 'object') { 1097: if ( 1098: (tool.name === FILE_READ_TOOL_NAME || 1099: tool.name === FILE_EDIT_TOOL_NAME || 1100: tool.name === FILE_WRITE_TOOL_NAME) && 1101: 'file_path' in processedInput 1102: ) { 1103: fileExtension = getFileExtensionForAnalytics( 1104: String(processedInput.file_path), 1105: ) 1106: } else if ( 1107: tool.name === NOTEBOOK_EDIT_TOOL_NAME && 1108: 'notebook_path' in processedInput 1109: ) { 1110: fileExtension = getFileExtensionForAnalytics( 1111: String(processedInput.notebook_path), 1112: ) 1113: } else if (tool.name === BASH_TOOL_NAME && 'command' in processedInput) { 1114: const bashInput = processedInput as BashToolInput 1115: fileExtension = getFileExtensionsFromBashCommand( 1116: bashInput.command, 1117: bashInput._simulatedSedEdit?.filePath, 1118: ) 1119: } 1120: } 1121: logEvent('tengu_tool_use_success', { 1122: messageID: 1123: messageId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1124: toolName: sanitizeToolNameForAnalytics(tool.name), 1125: isMcp: tool.isMcp ?? false, 1126: durationMs, 1127: preToolHookDurationMs, 1128: toolResultSizeBytes, 1129: ...(fileExtension !== undefined && { fileExtension }), 1130: queryChainId: toolUseContext.queryTracking 1131: ?.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1132: queryDepth: toolUseContext.queryTracking?.depth, 1133: ...(mcpServerType && { 1134: mcpServerType: 1135: mcpServerType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1136: }), 1137: ...(mcpServerBaseUrl && { 1138: mcpServerBaseUrl: 1139: mcpServerBaseUrl as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1140: }), 1141: ...(requestId && { 1142: requestId: 1143: requestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1144: }), 1145: ...mcpToolDetailsForAnalytics(tool.name, mcpServerType, mcpServerBaseUrl), 1146: }) 1147: if ( 1148: isToolDetailsLoggingEnabled() && 1149: (tool.name === BASH_TOOL_NAME || tool.name === POWERSHELL_TOOL_NAME) && 1150: 'command' in processedInput && 1151: typeof processedInput.command === 'string' && 1152: processedInput.command.match(/\bgit\s+commit\b/) && 1153: result.data && 1154: typeof result.data === 'object' && 1155: 'stdout' in result.data 1156: ) { 1157: const gitCommitId = parseGitCommitId(String(result.data.stdout)) 1158: if (gitCommitId) { 1159: toolParameters.git_commit_id = gitCommitId 1160: } 1161: } 1162: const mcpServerScope = isMcpTool(tool) 1163: ? getMcpServerScopeFromToolName(tool.name) 1164: : null 1165: void logOTelEvent('tool_result', { 1166: tool_name: sanitizeToolNameForAnalytics(tool.name), 1167: success: 'true', 1168: duration_ms: String(durationMs), 1169: ...(Object.keys(toolParameters).length > 0 && { 1170: tool_parameters: jsonStringify(toolParameters), 1171: }), 1172: ...(telemetryToolInput && { tool_input: telemetryToolInput }), 1173: tool_result_size_bytes: String(toolResultSizeBytes), 1174: ...(decisionInfo && { 1175: decision_source: decisionInfo.source, 1176: decision_type: decisionInfo.decision, 1177: }), 1178: ...(mcpServerScope && { mcp_server_scope: mcpServerScope }), 1179: }) 1180: let toolOutput = result.data 1181: const hookResults = [] 1182: const toolContextModifier = result.contextModifier 1183: const mcpMeta = result.mcpMeta 1184: async function addToolResult( 1185: toolUseResult: unknown, 1186: preMappedBlock?: ToolResultBlockParam, 1187: ) { 1188: const toolResultBlock = preMappedBlock 1189: ? await processPreMappedToolResultBlock( 1190: preMappedBlock, 1191: tool.name, 1192: tool.maxResultSizeChars, 1193: ) 1194: : await processToolResultBlock(tool, toolUseResult, toolUseID) 1195: const contentBlocks: ContentBlockParam[] = [toolResultBlock] 1196: if ( 1197: 'acceptFeedback' in permissionDecision && 1198: permissionDecision.acceptFeedback 1199: ) { 1200: contentBlocks.push({ 1201: type: 'text', 1202: text: permissionDecision.acceptFeedback, 1203: }) 1204: } 1205: const allowContentBlocks = 1206: 'contentBlocks' in permissionDecision 1207: ? permissionDecision.contentBlocks 1208: : undefined 1209: if (allowContentBlocks?.length) { 1210: contentBlocks.push(...allowContentBlocks) 1211: } 1212: let allowImageIds: number[] | undefined 1213: if (allowContentBlocks?.length) { 1214: const imageCount = count( 1215: allowContentBlocks, 1216: (b: ContentBlockParam) => b.type === 'image', 1217: ) 1218: if (imageCount > 0) { 1219: const startId = getNextImagePasteId(toolUseContext.messages) 1220: allowImageIds = Array.from( 1221: { length: imageCount }, 1222: (_, i) => startId + i, 1223: ) 1224: } 1225: } 1226: resultingMessages.push({ 1227: message: createUserMessage({ 1228: content: contentBlocks, 1229: imagePasteIds: allowImageIds, 1230: toolUseResult: 1231: toolUseContext.agentId && !toolUseContext.preserveToolUseResults 1232: ? undefined 1233: : toolUseResult, 1234: mcpMeta: toolUseContext.agentId ? undefined : mcpMeta, 1235: sourceToolAssistantUUID: assistantMessage.uuid, 1236: }), 1237: contextModifier: toolContextModifier 1238: ? { 1239: toolUseID: toolUseID, 1240: modifyContext: toolContextModifier, 1241: } 1242: : undefined, 1243: }) 1244: } 1245: if (!isMcpTool(tool)) { 1246: await addToolResult(toolOutput, mappedToolResultBlock) 1247: } 1248: const postToolHookInfos: StopHookInfo[] = [] 1249: const postToolHookStart = Date.now() 1250: for await (const hookResult of runPostToolUseHooks( 1251: toolUseContext, 1252: tool, 1253: toolUseID, 1254: assistantMessage.message.id, 1255: processedInput, 1256: toolOutput, 1257: requestId, 1258: mcpServerType, 1259: mcpServerBaseUrl, 1260: )) { 1261: if ('updatedMCPToolOutput' in hookResult) { 1262: if (isMcpTool(tool)) { 1263: toolOutput = hookResult.updatedMCPToolOutput 1264: } 1265: } else if (isMcpTool(tool)) { 1266: hookResults.push(hookResult) 1267: if (hookResult.message.type === 'attachment') { 1268: const att = hookResult.message.attachment 1269: if ( 1270: 'command' in att && 1271: att.command !== undefined && 1272: 'durationMs' in att && 1273: att.durationMs !== undefined 1274: ) { 1275: postToolHookInfos.push({ 1276: command: att.command, 1277: durationMs: att.durationMs, 1278: }) 1279: } 1280: } 1281: } else { 1282: resultingMessages.push(hookResult) 1283: if (hookResult.message.type === 'attachment') { 1284: const att = hookResult.message.attachment 1285: if ( 1286: 'command' in att && 1287: att.command !== undefined && 1288: 'durationMs' in att && 1289: att.durationMs !== undefined 1290: ) { 1291: postToolHookInfos.push({ 1292: command: att.command, 1293: durationMs: att.durationMs, 1294: }) 1295: } 1296: } 1297: } 1298: } 1299: const postToolHookDurationMs = Date.now() - postToolHookStart 1300: if (postToolHookDurationMs >= SLOW_PHASE_LOG_THRESHOLD_MS) { 1301: logForDebugging( 1302: `Slow PostToolUse hooks: ${postToolHookDurationMs}ms for ${tool.name} (${postToolHookInfos.length} hooks)`, 1303: { level: 'info' }, 1304: ) 1305: } 1306: if (isMcpTool(tool)) { 1307: await addToolResult(toolOutput) 1308: } 1309: if (process.env.USER_TYPE === 'ant' && postToolHookInfos.length > 0) { 1310: if (postToolHookDurationMs > HOOK_TIMING_DISPLAY_THRESHOLD_MS) { 1311: resultingMessages.push({ 1312: message: createStopHookSummaryMessage( 1313: postToolHookInfos.length, 1314: postToolHookInfos, 1315: [], 1316: false, 1317: undefined, 1318: false, 1319: 'suggestion', 1320: undefined, 1321: 'PostToolUse', 1322: postToolHookDurationMs, 1323: ), 1324: }) 1325: } 1326: } 1327: if (result.newMessages && result.newMessages.length > 0) { 1328: for (const message of result.newMessages) { 1329: resultingMessages.push({ message }) 1330: } 1331: } 1332: if (shouldPreventContinuation) { 1333: resultingMessages.push({ 1334: message: createAttachmentMessage({ 1335: type: 'hook_stopped_continuation', 1336: message: stopReason || 'Execution stopped by hook', 1337: hookName: `PreToolUse:${tool.name}`, 1338: toolUseID: toolUseID, 1339: hookEvent: 'PreToolUse', 1340: }), 1341: }) 1342: } 1343: for (const hookResult of hookResults) { 1344: resultingMessages.push(hookResult) 1345: } 1346: return resultingMessages 1347: } catch (error) { 1348: const durationMs = Date.now() - startTime 1349: addToToolDuration(durationMs) 1350: endToolExecutionSpan({ 1351: success: false, 1352: error: errorMessage(error), 1353: }) 1354: endToolSpan() 1355: if (error instanceof McpAuthError) { 1356: toolUseContext.setAppState(prevState => { 1357: const serverName = error.serverName 1358: const existingClientIndex = prevState.mcp.clients.findIndex( 1359: c => c.name === serverName, 1360: ) 1361: if (existingClientIndex === -1) { 1362: return prevState 1363: } 1364: const existingClient = prevState.mcp.clients[existingClientIndex] 1365: if (!existingClient || existingClient.type !== 'connected') { 1366: return prevState 1367: } 1368: const updatedClients = [...prevState.mcp.clients] 1369: updatedClients[existingClientIndex] = { 1370: name: serverName, 1371: type: 'needs-auth' as const, 1372: config: existingClient.config, 1373: } 1374: return { 1375: ...prevState, 1376: mcp: { 1377: ...prevState.mcp, 1378: clients: updatedClients, 1379: }, 1380: } 1381: }) 1382: } 1383: if (!(error instanceof AbortError)) { 1384: const errorMsg = errorMessage(error) 1385: logForDebugging( 1386: `${tool.name} tool error (${durationMs}ms): ${errorMsg.slice(0, 200)}`, 1387: ) 1388: if (!(error instanceof ShellError)) { 1389: logError(error) 1390: } 1391: logEvent('tengu_tool_use_error', { 1392: messageID: 1393: messageId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1394: toolName: sanitizeToolNameForAnalytics(tool.name), 1395: error: classifyToolError( 1396: error, 1397: ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1398: isMcp: tool.isMcp ?? false, 1399: queryChainId: toolUseContext.queryTracking 1400: ?.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1401: queryDepth: toolUseContext.queryTracking?.depth, 1402: ...(mcpServerType && { 1403: mcpServerType: 1404: mcpServerType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1405: }), 1406: ...(mcpServerBaseUrl && { 1407: mcpServerBaseUrl: 1408: mcpServerBaseUrl as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1409: }), 1410: ...(requestId && { 1411: requestId: 1412: requestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1413: }), 1414: ...mcpToolDetailsForAnalytics( 1415: tool.name, 1416: mcpServerType, 1417: mcpServerBaseUrl, 1418: ), 1419: }) 1420: const mcpServerScope = isMcpTool(tool) 1421: ? getMcpServerScopeFromToolName(tool.name) 1422: : null 1423: void logOTelEvent('tool_result', { 1424: tool_name: sanitizeToolNameForAnalytics(tool.name), 1425: use_id: toolUseID, 1426: success: 'false', 1427: duration_ms: String(durationMs), 1428: error: errorMessage(error), 1429: ...(Object.keys(toolParameters).length > 0 && { 1430: tool_parameters: jsonStringify(toolParameters), 1431: }), 1432: ...(telemetryToolInput && { tool_input: telemetryToolInput }), 1433: ...(decisionInfo && { 1434: decision_source: decisionInfo.source, 1435: decision_type: decisionInfo.decision, 1436: }), 1437: ...(mcpServerScope && { mcp_server_scope: mcpServerScope }), 1438: }) 1439: } 1440: const content = formatError(error) 1441: const isInterrupt = error instanceof AbortError 1442: const hookMessages: MessageUpdateLazy< 1443: AttachmentMessage | ProgressMessage 1444: >[] = [] 1445: for await (const hookResult of runPostToolUseFailureHooks( 1446: toolUseContext, 1447: tool, 1448: toolUseID, 1449: messageId, 1450: processedInput, 1451: content, 1452: isInterrupt, 1453: requestId, 1454: mcpServerType, 1455: mcpServerBaseUrl, 1456: )) { 1457: hookMessages.push(hookResult) 1458: } 1459: return [ 1460: { 1461: message: createUserMessage({ 1462: content: [ 1463: { 1464: type: 'tool_result', 1465: content, 1466: is_error: true, 1467: tool_use_id: toolUseID, 1468: }, 1469: ], 1470: toolUseResult: `Error: ${content}`, 1471: mcpMeta: toolUseContext.agentId 1472: ? undefined 1473: : error instanceof 1474: McpToolCallError_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 1475: ? error.mcpMeta 1476: : undefined, 1477: sourceToolAssistantUUID: assistantMessage.uuid, 1478: }), 1479: }, 1480: ...hookMessages, 1481: ] 1482: } finally { 1483: stopSessionActivity('tool_exec') 1484: if (decisionInfo) { 1485: toolUseContext.toolDecisions?.delete(toolUseID) 1486: } 1487: } 1488: } ````

File: src/services/tools/toolHooks.ts

typescript 1: import { 2: type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 3: logEvent, 4: } from 'src/services/analytics/index.js' 5: import { sanitizeToolNameForAnalytics } from 'src/services/analytics/metadata.js' 6: import type z from 'zod/v4' 7: import type { CanUseToolFn } from '../../hooks/useCanUseTool.js' 8: import type { AnyObject, Tool, ToolUseContext } from '../../Tool.js' 9: import type { HookProgress } from '../../types/hooks.js' 10: import type { 11: AssistantMessage, 12: AttachmentMessage, 13: ProgressMessage, 14: } from '../../types/message.js' 15: import type { PermissionDecision } from '../../types/permissions.js' 16: import { createAttachmentMessage } from '../../utils/attachments.js' 17: import { logForDebugging } from '../../utils/debug.js' 18: import { 19: executePostToolHooks, 20: executePostToolUseFailureHooks, 21: executePreToolHooks, 22: getPreToolHookBlockingMessage, 23: } from '../../utils/hooks.js' 24: import { logError } from '../../utils/log.js' 25: import { 26: getRuleBehaviorDescription, 27: type PermissionDecisionReason, 28: type PermissionResult, 29: } from '../../utils/permissions/PermissionResult.js' 30: import { checkRuleBasedPermissions } from '../../utils/permissions/permissions.js' 31: import { formatError } from '../../utils/toolErrors.js' 32: import { isMcpTool } from '../mcp/utils.js' 33: import type { McpServerType, MessageUpdateLazy } from './toolExecution.js' 34: export type PostToolUseHooksResult<Output> = 35: | MessageUpdateLazy<AttachmentMessage | ProgressMessage<HookProgress>> 36: | { updatedMCPToolOutput: Output } 37: export async function* runPostToolUseHooks<Input extends AnyObject, Output>( 38: toolUseContext: ToolUseContext, 39: tool: Tool<Input, Output>, 40: toolUseID: string, 41: messageId: string, 42: toolInput: Record<string, unknown>, 43: toolResponse: Output, 44: requestId: string | undefined, 45: mcpServerType: McpServerType, 46: mcpServerBaseUrl: string | undefined, 47: ): AsyncGenerator<PostToolUseHooksResult<Output>> { 48: const postToolStartTime = Date.now() 49: try { 50: const appState = toolUseContext.getAppState() 51: const permissionMode = appState.toolPermissionContext.mode 52: let toolOutput = toolResponse 53: for await (const result of executePostToolHooks( 54: tool.name, 55: toolUseID, 56: toolInput, 57: toolOutput, 58: toolUseContext, 59: permissionMode, 60: toolUseContext.abortController.signal, 61: )) { 62: try { 63: if ( 64: result.message?.type === 'attachment' && 65: result.message.attachment.type === 'hook_cancelled' 66: ) { 67: logEvent('tengu_post_tool_hooks_cancelled', { 68: toolName: sanitizeToolNameForAnalytics(tool.name), 69: queryChainId: toolUseContext.queryTracking 70: ?.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 71: queryDepth: toolUseContext.queryTracking?.depth, 72: }) 73: yield { 74: message: createAttachmentMessage({ 75: type: 'hook_cancelled', 76: hookName: `PostToolUse:${tool.name}`, 77: toolUseID, 78: hookEvent: 'PostToolUse', 79: }), 80: } 81: continue 82: } 83: if ( 84: result.message && 85: !( 86: result.message.type === 'attachment' && 87: result.message.attachment.type === 'hook_blocking_error' 88: ) 89: ) { 90: yield { message: result.message } 91: } 92: if (result.blockingError) { 93: yield { 94: message: createAttachmentMessage({ 95: type: 'hook_blocking_error', 96: hookName: `PostToolUse:${tool.name}`, 97: toolUseID: toolUseID, 98: hookEvent: 'PostToolUse', 99: blockingError: result.blockingError, 100: }), 101: } 102: } 103: if (result.preventContinuation) { 104: yield { 105: message: createAttachmentMessage({ 106: type: 'hook_stopped_continuation', 107: message: 108: result.stopReason || 'Execution stopped by PostToolUse hook', 109: hookName: `PostToolUse:${tool.name}`, 110: toolUseID: toolUseID, 111: hookEvent: 'PostToolUse', 112: }), 113: } 114: return 115: } 116: if (result.additionalContexts && result.additionalContexts.length > 0) { 117: yield { 118: message: createAttachmentMessage({ 119: type: 'hook_additional_context', 120: content: result.additionalContexts, 121: hookName: `PostToolUse:${tool.name}`, 122: toolUseID: toolUseID, 123: hookEvent: 'PostToolUse', 124: }), 125: } 126: } 127: if (result.updatedMCPToolOutput && isMcpTool(tool)) { 128: toolOutput = result.updatedMCPToolOutput as Output 129: yield { 130: updatedMCPToolOutput: toolOutput, 131: } 132: } 133: } catch (error) { 134: const postToolDurationMs = Date.now() - postToolStartTime 135: logEvent('tengu_post_tool_hook_error', { 136: messageID: 137: messageId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 138: toolName: sanitizeToolNameForAnalytics(tool.name), 139: isMcp: tool.isMcp ?? false, 140: duration: postToolDurationMs, 141: queryChainId: toolUseContext.queryTracking 142: ?.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 143: queryDepth: toolUseContext.queryTracking?.depth, 144: ...(mcpServerType 145: ? { 146: mcpServerType: 147: mcpServerType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 148: } 149: : {}), 150: ...(requestId 151: ? { 152: requestId: 153: requestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 154: } 155: : {}), 156: }) 157: yield { 158: message: createAttachmentMessage({ 159: type: 'hook_error_during_execution', 160: content: formatError(error), 161: hookName: `PostToolUse:${tool.name}`, 162: toolUseID: toolUseID, 163: hookEvent: 'PostToolUse', 164: }), 165: } 166: } 167: } 168: } catch (error) { 169: logError(error) 170: } 171: } 172: export async function* runPostToolUseFailureHooks<Input extends AnyObject>( 173: toolUseContext: ToolUseContext, 174: tool: Tool<Input, unknown>, 175: toolUseID: string, 176: messageId: string, 177: processedInput: z.infer<Input>, 178: error: string, 179: isInterrupt: boolean | undefined, 180: requestId: string | undefined, 181: mcpServerType: McpServerType, 182: mcpServerBaseUrl: string | undefined, 183: ): AsyncGenerator< 184: MessageUpdateLazy<AttachmentMessage | ProgressMessage<HookProgress>> 185: > { 186: const postToolStartTime = Date.now() 187: try { 188: const appState = toolUseContext.getAppState() 189: const permissionMode = appState.toolPermissionContext.mode 190: for await (const result of executePostToolUseFailureHooks( 191: tool.name, 192: toolUseID, 193: processedInput, 194: error, 195: toolUseContext, 196: isInterrupt, 197: permissionMode, 198: toolUseContext.abortController.signal, 199: )) { 200: try { 201: if ( 202: result.message?.type === 'attachment' && 203: result.message.attachment.type === 'hook_cancelled' 204: ) { 205: logEvent('tengu_post_tool_failure_hooks_cancelled', { 206: toolName: sanitizeToolNameForAnalytics(tool.name), 207: queryChainId: toolUseContext.queryTracking 208: ?.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 209: queryDepth: toolUseContext.queryTracking?.depth, 210: }) 211: yield { 212: message: createAttachmentMessage({ 213: type: 'hook_cancelled', 214: hookName: `PostToolUseFailure:${tool.name}`, 215: toolUseID, 216: hookEvent: 'PostToolUseFailure', 217: }), 218: } 219: continue 220: } 221: if ( 222: result.message && 223: !( 224: result.message.type === 'attachment' && 225: result.message.attachment.type === 'hook_blocking_error' 226: ) 227: ) { 228: yield { message: result.message } 229: } 230: if (result.blockingError) { 231: yield { 232: message: createAttachmentMessage({ 233: type: 'hook_blocking_error', 234: hookName: `PostToolUseFailure:${tool.name}`, 235: toolUseID: toolUseID, 236: hookEvent: 'PostToolUseFailure', 237: blockingError: result.blockingError, 238: }), 239: } 240: } 241: if (result.additionalContexts && result.additionalContexts.length > 0) { 242: yield { 243: message: createAttachmentMessage({ 244: type: 'hook_additional_context', 245: content: result.additionalContexts, 246: hookName: `PostToolUseFailure:${tool.name}`, 247: toolUseID: toolUseID, 248: hookEvent: 'PostToolUseFailure', 249: }), 250: } 251: } 252: } catch (hookError) { 253: const postToolDurationMs = Date.now() - postToolStartTime 254: logEvent('tengu_post_tool_failure_hook_error', { 255: messageID: 256: messageId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 257: toolName: sanitizeToolNameForAnalytics(tool.name), 258: isMcp: tool.isMcp ?? false, 259: duration: postToolDurationMs, 260: queryChainId: toolUseContext.queryTracking 261: ?.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 262: queryDepth: toolUseContext.queryTracking?.depth, 263: ...(mcpServerType 264: ? { 265: mcpServerType: 266: mcpServerType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 267: } 268: : {}), 269: ...(requestId 270: ? { 271: requestId: 272: requestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 273: } 274: : {}), 275: }) 276: yield { 277: message: createAttachmentMessage({ 278: type: 'hook_error_during_execution', 279: content: formatError(hookError), 280: hookName: `PostToolUseFailure:${tool.name}`, 281: toolUseID: toolUseID, 282: hookEvent: 'PostToolUseFailure', 283: }), 284: } 285: } 286: } 287: } catch (outerError) { 288: logError(outerError) 289: } 290: } 291: export async function resolveHookPermissionDecision( 292: hookPermissionResult: PermissionResult | undefined, 293: tool: Tool, 294: input: Record<string, unknown>, 295: toolUseContext: ToolUseContext, 296: canUseTool: CanUseToolFn, 297: assistantMessage: AssistantMessage, 298: toolUseID: string, 299: ): Promise<{ 300: decision: PermissionDecision 301: input: Record<string, unknown> 302: }> { 303: const requiresInteraction = tool.requiresUserInteraction?.() 304: const requireCanUseTool = toolUseContext.requireCanUseTool 305: if (hookPermissionResult?.behavior === 'allow') { 306: const hookInput = hookPermissionResult.updatedInput ?? input 307: const interactionSatisfied = 308: requiresInteraction && hookPermissionResult.updatedInput !== undefined 309: if ((requiresInteraction && !interactionSatisfied) || requireCanUseTool) { 310: logForDebugging( 311: `Hook approved tool use for ${tool.name}, but canUseTool is required`, 312: ) 313: return { 314: decision: await canUseTool( 315: tool, 316: hookInput, 317: toolUseContext, 318: assistantMessage, 319: toolUseID, 320: ), 321: input: hookInput, 322: } 323: } 324: const ruleCheck = await checkRuleBasedPermissions( 325: tool, 326: hookInput, 327: toolUseContext, 328: ) 329: if (ruleCheck === null) { 330: logForDebugging( 331: interactionSatisfied 332: ? `Hook satisfied user interaction for ${tool.name} via updatedInput` 333: : `Hook approved tool use for ${tool.name}, bypassing permission prompt`, 334: ) 335: return { decision: hookPermissionResult, input: hookInput } 336: } 337: if (ruleCheck.behavior === 'deny') { 338: logForDebugging( 339: `Hook approved tool use for ${tool.name}, but deny rule overrides: ${ruleCheck.message}`, 340: ) 341: return { decision: ruleCheck, input: hookInput } 342: } 343: logForDebugging( 344: `Hook approved tool use for ${tool.name}, but ask rule requires prompt`, 345: ) 346: return { 347: decision: await canUseTool( 348: tool, 349: hookInput, 350: toolUseContext, 351: assistantMessage, 352: toolUseID, 353: ), 354: input: hookInput, 355: } 356: } 357: if (hookPermissionResult?.behavior === 'deny') { 358: logForDebugging(`Hook denied tool use for ${tool.name}`) 359: return { decision: hookPermissionResult, input } 360: } 361: const forceDecision = 362: hookPermissionResult?.behavior === 'ask' ? hookPermissionResult : undefined 363: const askInput = 364: hookPermissionResult?.behavior === 'ask' && 365: hookPermissionResult.updatedInput 366: ? hookPermissionResult.updatedInput 367: : input 368: return { 369: decision: await canUseTool( 370: tool, 371: askInput, 372: toolUseContext, 373: assistantMessage, 374: toolUseID, 375: forceDecision, 376: ), 377: input: askInput, 378: } 379: } 380: export async function* runPreToolUseHooks( 381: toolUseContext: ToolUseContext, 382: tool: Tool, 383: processedInput: Record<string, unknown>, 384: toolUseID: string, 385: messageId: string, 386: requestId: string | undefined, 387: mcpServerType: McpServerType, 388: mcpServerBaseUrl: string | undefined, 389: ): AsyncGenerator< 390: | { 391: type: 'message' 392: message: MessageUpdateLazy< 393: AttachmentMessage | ProgressMessage<HookProgress> 394: > 395: } 396: | { type: 'hookPermissionResult'; hookPermissionResult: PermissionResult } 397: | { type: 'hookUpdatedInput'; updatedInput: Record<string, unknown> } 398: | { type: 'preventContinuation'; shouldPreventContinuation: boolean } 399: | { type: 'stopReason'; stopReason: string } 400: | { 401: type: 'additionalContext' 402: message: MessageUpdateLazy<AttachmentMessage> 403: } 404: | { type: 'stop' } 405: > { 406: const hookStartTime = Date.now() 407: try { 408: const appState = toolUseContext.getAppState() 409: for await (const result of executePreToolHooks( 410: tool.name, 411: toolUseID, 412: processedInput, 413: toolUseContext, 414: appState.toolPermissionContext.mode, 415: toolUseContext.abortController.signal, 416: undefined, 417: toolUseContext.requestPrompt, 418: tool.getToolUseSummary?.(processedInput), 419: )) { 420: try { 421: if (result.message) { 422: yield { type: 'message', message: { message: result.message } } 423: } 424: if (result.blockingError) { 425: const denialMessage = getPreToolHookBlockingMessage( 426: `PreToolUse:${tool.name}`, 427: result.blockingError, 428: ) 429: yield { 430: type: 'hookPermissionResult', 431: hookPermissionResult: { 432: behavior: 'deny', 433: message: denialMessage, 434: decisionReason: { 435: type: 'hook', 436: hookName: `PreToolUse:${tool.name}`, 437: reason: denialMessage, 438: }, 439: }, 440: } 441: } 442: if (result.preventContinuation) { 443: yield { 444: type: 'preventContinuation', 445: shouldPreventContinuation: true, 446: } 447: if (result.stopReason) { 448: yield { type: 'stopReason', stopReason: result.stopReason } 449: } 450: } 451: if (result.permissionBehavior !== undefined) { 452: logForDebugging( 453: `Hook result has permissionBehavior=${result.permissionBehavior}`, 454: ) 455: const decisionReason: PermissionDecisionReason = { 456: type: 'hook', 457: hookName: `PreToolUse:${tool.name}`, 458: hookSource: result.hookSource, 459: reason: result.hookPermissionDecisionReason, 460: } 461: if (result.permissionBehavior === 'allow') { 462: yield { 463: type: 'hookPermissionResult', 464: hookPermissionResult: { 465: behavior: 'allow', 466: updatedInput: result.updatedInput, 467: decisionReason, 468: }, 469: } 470: } else if (result.permissionBehavior === 'ask') { 471: yield { 472: type: 'hookPermissionResult', 473: hookPermissionResult: { 474: behavior: 'ask', 475: updatedInput: result.updatedInput, 476: message: 477: result.hookPermissionDecisionReason || 478: `Hook PreToolUse:${tool.name} ${getRuleBehaviorDescription(result.permissionBehavior)} this tool`, 479: decisionReason, 480: }, 481: } 482: } else { 483: yield { 484: type: 'hookPermissionResult', 485: hookPermissionResult: { 486: behavior: result.permissionBehavior, 487: message: 488: result.hookPermissionDecisionReason || 489: `Hook PreToolUse:${tool.name} ${getRuleBehaviorDescription(result.permissionBehavior)} this tool`, 490: decisionReason, 491: }, 492: } 493: } 494: } 495: if (result.updatedInput && result.permissionBehavior === undefined) { 496: yield { 497: type: 'hookUpdatedInput', 498: updatedInput: result.updatedInput, 499: } 500: } 501: if (result.additionalContexts && result.additionalContexts.length > 0) { 502: yield { 503: type: 'additionalContext', 504: message: { 505: message: createAttachmentMessage({ 506: type: 'hook_additional_context', 507: content: result.additionalContexts, 508: hookName: `PreToolUse:${tool.name}`, 509: toolUseID, 510: hookEvent: 'PreToolUse', 511: }), 512: }, 513: } 514: } 515: if (toolUseContext.abortController.signal.aborted) { 516: logEvent('tengu_pre_tool_hooks_cancelled', { 517: toolName: sanitizeToolNameForAnalytics(tool.name), 518: queryChainId: toolUseContext.queryTracking 519: ?.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 520: queryDepth: toolUseContext.queryTracking?.depth, 521: }) 522: yield { 523: type: 'message', 524: message: { 525: message: createAttachmentMessage({ 526: type: 'hook_cancelled', 527: hookName: `PreToolUse:${tool.name}`, 528: toolUseID, 529: hookEvent: 'PreToolUse', 530: }), 531: }, 532: } 533: yield { type: 'stop' } 534: return 535: } 536: } catch (error) { 537: logError(error) 538: const durationMs = Date.now() - hookStartTime 539: logEvent('tengu_pre_tool_hook_error', { 540: messageID: 541: messageId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 542: toolName: sanitizeToolNameForAnalytics(tool.name), 543: isMcp: tool.isMcp ?? false, 544: duration: durationMs, 545: queryChainId: toolUseContext.queryTracking 546: ?.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 547: queryDepth: toolUseContext.queryTracking?.depth, 548: ...(mcpServerType 549: ? { 550: mcpServerType: 551: mcpServerType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 552: } 553: : {}), 554: ...(requestId 555: ? { 556: requestId: 557: requestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 558: } 559: : {}), 560: }) 561: yield { 562: type: 'message', 563: message: { 564: message: createAttachmentMessage({ 565: type: 'hook_error_during_execution', 566: content: formatError(error), 567: hookName: `PreToolUse:${tool.name}`, 568: toolUseID: toolUseID, 569: hookEvent: 'PreToolUse', 570: }), 571: }, 572: } 573: yield { type: 'stop' } 574: } 575: } 576: } catch (error) { 577: logError(error) 578: yield { type: 'stop' } 579: return 580: } 581: }

File: src/services/tools/toolOrchestration.ts

typescript 1: import type { ToolUseBlock } from '@anthropic-ai/sdk/resources/index.mjs' 2: import type { CanUseToolFn } from '../../hooks/useCanUseTool.js' 3: import { findToolByName, type ToolUseContext } from '../../Tool.js' 4: import type { AssistantMessage, Message } from '../../types/message.js' 5: import { all } from '../../utils/generators.js' 6: import { type MessageUpdateLazy, runToolUse } from './toolExecution.js' 7: function getMaxToolUseConcurrency(): number { 8: return ( 9: parseInt(process.env.CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY || '', 10) || 10 10: ) 11: } 12: export type MessageUpdate = { 13: message?: Message 14: newContext: ToolUseContext 15: } 16: export async function* runTools( 17: toolUseMessages: ToolUseBlock[], 18: assistantMessages: AssistantMessage[], 19: canUseTool: CanUseToolFn, 20: toolUseContext: ToolUseContext, 21: ): AsyncGenerator<MessageUpdate, void> { 22: let currentContext = toolUseContext 23: for (const { isConcurrencySafe, blocks } of partitionToolCalls( 24: toolUseMessages, 25: currentContext, 26: )) { 27: if (isConcurrencySafe) { 28: const queuedContextModifiers: Record< 29: string, 30: ((context: ToolUseContext) => ToolUseContext)[] 31: > = {} 32: // Run read-only batch concurrently 33: for await (const update of runToolsConcurrently( 34: blocks, 35: assistantMessages, 36: canUseTool, 37: currentContext, 38: )) { 39: if (update.contextModifier) { 40: const { toolUseID, modifyContext } = update.contextModifier 41: if (!queuedContextModifiers[toolUseID]) { 42: queuedContextModifiers[toolUseID] = [] 43: } 44: queuedContextModifiers[toolUseID].push(modifyContext) 45: } 46: yield { 47: message: update.message, 48: newContext: currentContext, 49: } 50: } 51: for (const block of blocks) { 52: const modifiers = queuedContextModifiers[block.id] 53: if (!modifiers) { 54: continue 55: } 56: for (const modifier of modifiers) { 57: currentContext = modifier(currentContext) 58: } 59: } 60: yield { newContext: currentContext } 61: } else { 62: // Run non-read-only batch serially 63: for await (const update of runToolsSerially( 64: blocks, 65: assistantMessages, 66: canUseTool, 67: currentContext, 68: )) { 69: if (update.newContext) { 70: currentContext = update.newContext 71: } 72: yield { 73: message: update.message, 74: newContext: currentContext, 75: } 76: } 77: } 78: } 79: } 80: type Batch = { isConcurrencySafe: boolean; blocks: ToolUseBlock[] } 81: /** 82: * Partition tool calls into batches where each batch is either: 83: * 1. A single non-read-only tool, or 84: * 2. Multiple consecutive read-only tools 85: */ 86: function partitionToolCalls( 87: toolUseMessages: ToolUseBlock[], 88: toolUseContext: ToolUseContext, 89: ): Batch[] { 90: return toolUseMessages.reduce((acc: Batch[], toolUse) => { 91: const tool = findToolByName(toolUseContext.options.tools, toolUse.name) 92: const parsedInput = tool?.inputSchema.safeParse(toolUse.input) 93: const isConcurrencySafe = parsedInput?.success 94: ? (() => { 95: try { 96: return Boolean(tool?.isConcurrencySafe(parsedInput.data)) 97: } catch { 98: // If isConcurrencySafe throws (e.g., due to shell-quote parse failure), 99: // treat as not concurrency-safe to be conservative 100: return false 101: } 102: })() 103: : false 104: if (isConcurrencySafe && acc[acc.length - 1]?.isConcurrencySafe) { 105: acc[acc.length - 1]!.blocks.push(toolUse) 106: } else { 107: acc.push({ isConcurrencySafe, blocks: [toolUse] }) 108: } 109: return acc 110: }, []) 111: } 112: async function* runToolsSerially( 113: toolUseMessages: ToolUseBlock[], 114: assistantMessages: AssistantMessage[], 115: canUseTool: CanUseToolFn, 116: toolUseContext: ToolUseContext, 117: ): AsyncGenerator<MessageUpdate, void> { 118: let currentContext = toolUseContext 119: for (const toolUse of toolUseMessages) { 120: toolUseContext.setInProgressToolUseIDs(prev => 121: new Set(prev).add(toolUse.id), 122: ) 123: for await (const update of runToolUse( 124: toolUse, 125: assistantMessages.find(_ => 126: _.message.content.some( 127: _ => _.type === 'tool_use' && _.id === toolUse.id, 128: ), 129: )!, 130: canUseTool, 131: currentContext, 132: )) { 133: if (update.contextModifier) { 134: currentContext = update.contextModifier.modifyContext(currentContext) 135: } 136: yield { 137: message: update.message, 138: newContext: currentContext, 139: } 140: } 141: markToolUseAsComplete(toolUseContext, toolUse.id) 142: } 143: } 144: async function* runToolsConcurrently( 145: toolUseMessages: ToolUseBlock[], 146: assistantMessages: AssistantMessage[], 147: canUseTool: CanUseToolFn, 148: toolUseContext: ToolUseContext, 149: ): AsyncGenerator<MessageUpdateLazy, void> { 150: yield* all( 151: toolUseMessages.map(async function* (toolUse) { 152: toolUseContext.setInProgressToolUseIDs(prev => 153: new Set(prev).add(toolUse.id), 154: ) 155: yield* runToolUse( 156: toolUse, 157: assistantMessages.find(_ => 158: _.message.content.some( 159: _ => _.type === 'tool_use' && _.id === toolUse.id, 160: ), 161: )!, 162: canUseTool, 163: toolUseContext, 164: ) 165: markToolUseAsComplete(toolUseContext, toolUse.id) 166: }), 167: getMaxToolUseConcurrency(), 168: ) 169: } 170: function markToolUseAsComplete( 171: toolUseContext: ToolUseContext, 172: toolUseID: string, 173: ) { 174: toolUseContext.setInProgressToolUseIDs(prev => { 175: const next = new Set(prev) 176: next.delete(toolUseID) 177: return next 178: }) 179: }

File: src/services/toolUseSummary/toolUseSummaryGenerator.ts

typescript 1: import { E_TOOL_USE_SUMMARY_GENERATION_FAILED } from '../../constants/errorIds.js' 2: import { toError } from '../../utils/errors.js' 3: import { logError } from '../../utils/log.js' 4: import { jsonStringify } from '../../utils/slowOperations.js' 5: import { asSystemPrompt } from '../../utils/systemPromptType.js' 6: import { queryHaiku } from '../api/claude.js' 7: const TOOL_USE_SUMMARY_SYSTEM_PROMPT = `Write a short summary label describing what these tool calls accomplished. It appears as a single-line row in a mobile app and truncates around 30 characters, so think git-commit-subject, not sentence. 8: Keep the verb in past tense and the most distinctive noun. Drop articles, connectors, and long location context first. 9: Examples: 10: - Searched in auth/ 11: - Fixed NPE in UserService 12: - Created signup endpoint 13: - Read config.json 14: - Ran failing tests` 15: type ToolInfo = { 16: name: string 17: input: unknown 18: output: unknown 19: } 20: export type GenerateToolUseSummaryParams = { 21: tools: ToolInfo[] 22: signal: AbortSignal 23: isNonInteractiveSession: boolean 24: lastAssistantText?: string 25: } 26: export async function generateToolUseSummary({ 27: tools, 28: signal, 29: isNonInteractiveSession, 30: lastAssistantText, 31: }: GenerateToolUseSummaryParams): Promise<string | null> { 32: if (tools.length === 0) { 33: return null 34: } 35: try { 36: const toolSummaries = tools 37: .map(tool => { 38: const inputStr = truncateJson(tool.input, 300) 39: const outputStr = truncateJson(tool.output, 300) 40: return `Tool: ${tool.name}\nInput: ${inputStr}\nOutput: ${outputStr}` 41: }) 42: .join('\n\n') 43: const contextPrefix = lastAssistantText 44: ? `User's intent (from assistant's last message): ${lastAssistantText.slice(0, 200)}\n\n` 45: : '' 46: const response = await queryHaiku({ 47: systemPrompt: asSystemPrompt([TOOL_USE_SUMMARY_SYSTEM_PROMPT]), 48: userPrompt: `${contextPrefix}Tools completed:\n\n${toolSummaries}\n\nLabel:`, 49: signal, 50: options: { 51: querySource: 'tool_use_summary_generation', 52: enablePromptCaching: true, 53: agents: [], 54: isNonInteractiveSession, 55: hasAppendSystemPrompt: false, 56: mcpTools: [], 57: }, 58: }) 59: const summary = response.message.content 60: .filter(block => block.type === 'text') 61: .map(block => (block.type === 'text' ? block.text : '')) 62: .join('') 63: .trim() 64: return summary || null 65: } catch (error) { 66: // Log but don't fail - summaries are non-critical 67: const err = toError(error) 68: err.cause = { errorId: E_TOOL_USE_SUMMARY_GENERATION_FAILED } 69: logError(err) 70: return null 71: } 72: } 73: function truncateJson(value: unknown, maxLength: number): string { 74: try { 75: const str = jsonStringify(value) 76: if (str.length <= maxLength) { 77: return str 78: } 79: return str.slice(0, maxLength - 3) + '...' 80: } catch { 81: return '[unable to serialize]' 82: } 83: }

File: src/services/awaySummary.ts

typescript 1: import { APIUserAbortError } from '@anthropic-ai/sdk' 2: import { getEmptyToolPermissionContext } from '../Tool.js' 3: import type { Message } from '../types/message.js' 4: import { logForDebugging } from '../utils/debug.js' 5: import { 6: createUserMessage, 7: getAssistantMessageText, 8: } from '../utils/messages.js' 9: import { getSmallFastModel } from '../utils/model/model.js' 10: import { asSystemPrompt } from '../utils/systemPromptType.js' 11: import { queryModelWithoutStreaming } from './api/claude.js' 12: import { getSessionMemoryContent } from './SessionMemory/sessionMemoryUtils.js' 13: const RECENT_MESSAGE_WINDOW = 30 14: function buildAwaySummaryPrompt(memory: string | null): string { 15: const memoryBlock = memory 16: ? `Session memory (broader context):\n${memory}\n\n` 17: : '' 18: return `${memoryBlock}The user stepped away and is coming back. Write exactly 1-3 short sentences. Start by stating the high-level task — what they are building or debugging, not implementation details. Next: the concrete next step. Skip status reports and commit recaps.` 19: } 20: /** 21: * Generates a short session recap for the "while you were away" card. 22: * Returns null on abort, empty transcript, or error. 23: */ 24: export async function generateAwaySummary( 25: messages: readonly Message[], 26: signal: AbortSignal, 27: ): Promise<string | null> { 28: if (messages.length === 0) { 29: return null 30: } 31: try { 32: const memory = await getSessionMemoryContent() 33: const recent = messages.slice(-RECENT_MESSAGE_WINDOW) 34: recent.push(createUserMessage({ content: buildAwaySummaryPrompt(memory) })) 35: const response = await queryModelWithoutStreaming({ 36: messages: recent, 37: systemPrompt: asSystemPrompt([]), 38: thinkingConfig: { type: 'disabled' }, 39: tools: [], 40: signal, 41: options: { 42: getToolPermissionContext: async () => getEmptyToolPermissionContext(), 43: model: getSmallFastModel(), 44: toolChoice: undefined, 45: isNonInteractiveSession: false, 46: hasAppendSystemPrompt: false, 47: agents: [], 48: querySource: 'away_summary', 49: mcpTools: [], 50: skipCacheWrite: true, 51: }, 52: }) 53: if (response.isApiErrorMessage) { 54: logForDebugging( 55: `[awaySummary] API error: ${getAssistantMessageText(response)}`, 56: ) 57: return null 58: } 59: return getAssistantMessageText(response) 60: } catch (err) { 61: if (err instanceof APIUserAbortError || signal.aborted) { 62: return null 63: } 64: logForDebugging(`[awaySummary] generation failed: ${err}`) 65: return null 66: } 67: }

File: src/services/claudeAiLimits.ts

typescript 1: import { APIError } from '@anthropic-ai/sdk' 2: import type { MessageParam } from '@anthropic-ai/sdk/resources/index.mjs' 3: import isEqual from 'lodash-es/isEqual.js' 4: import { getIsNonInteractiveSession } from '../bootstrap/state.js' 5: import { isClaudeAISubscriber } from '../utils/auth.js' 6: import { getModelBetas } from '../utils/betas.js' 7: import { getGlobalConfig, saveGlobalConfig } from '../utils/config.js' 8: import { logError } from '../utils/log.js' 9: import { getSmallFastModel } from '../utils/model/model.js' 10: import { isEssentialTrafficOnly } from '../utils/privacyLevel.js' 11: import type { AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS } from './analytics/index.js' 12: import { logEvent } from './analytics/index.js' 13: import { getAPIMetadata } from './api/claude.js' 14: import { getAnthropicClient } from './api/client.js' 15: import { 16: processRateLimitHeaders, 17: shouldProcessRateLimits, 18: } from './rateLimitMocking.js' 19: export { 20: getRateLimitErrorMessage, 21: getRateLimitWarning, 22: getUsingOverageText, 23: } from './rateLimitMessages.js' 24: type QuotaStatus = 'allowed' | 'allowed_warning' | 'rejected' 25: type RateLimitType = 26: | 'five_hour' 27: | 'seven_day' 28: | 'seven_day_opus' 29: | 'seven_day_sonnet' 30: | 'overage' 31: export type { RateLimitType } 32: type EarlyWarningThreshold = { 33: utilization: number 34: timePct: number 35: } 36: type EarlyWarningConfig = { 37: rateLimitType: RateLimitType 38: claimAbbrev: '5h' | '7d' 39: windowSeconds: number 40: thresholds: EarlyWarningThreshold[] 41: } 42: const EARLY_WARNING_CONFIGS: EarlyWarningConfig[] = [ 43: { 44: rateLimitType: 'five_hour', 45: claimAbbrev: '5h', 46: windowSeconds: 5 * 60 * 60, 47: thresholds: [{ utilization: 0.9, timePct: 0.72 }], 48: }, 49: { 50: rateLimitType: 'seven_day', 51: claimAbbrev: '7d', 52: windowSeconds: 7 * 24 * 60 * 60, 53: thresholds: [ 54: { utilization: 0.75, timePct: 0.6 }, 55: { utilization: 0.5, timePct: 0.35 }, 56: { utilization: 0.25, timePct: 0.15 }, 57: ], 58: }, 59: ] 60: const EARLY_WARNING_CLAIM_MAP: Record<string, RateLimitType> = { 61: '5h': 'five_hour', 62: '7d': 'seven_day', 63: overage: 'overage', 64: } 65: const RATE_LIMIT_DISPLAY_NAMES: Record<RateLimitType, string> = { 66: five_hour: 'session limit', 67: seven_day: 'weekly limit', 68: seven_day_opus: 'Opus limit', 69: seven_day_sonnet: 'Sonnet limit', 70: overage: 'extra usage limit', 71: } 72: export function getRateLimitDisplayName(type: RateLimitType): string { 73: return RATE_LIMIT_DISPLAY_NAMES[type] || type 74: } 75: function computeTimeProgress(resetsAt: number, windowSeconds: number): number { 76: const nowSeconds = Date.now() / 1000 77: const windowStart = resetsAt - windowSeconds 78: const elapsed = nowSeconds - windowStart 79: return Math.max(0, Math.min(1, elapsed / windowSeconds)) 80: } 81: export type OverageDisabledReason = 82: | 'overage_not_provisioned' 83: | 'org_level_disabled' 84: | 'org_level_disabled_until' 85: | 'out_of_credits' 86: | 'seat_tier_level_disabled' 87: | 'member_level_disabled' 88: | 'seat_tier_zero_credit_limit' 89: | 'group_zero_credit_limit' 90: | 'member_zero_credit_limit' 91: | 'org_service_level_disabled' 92: | 'org_service_zero_credit_limit' 93: | 'no_limits_configured' 94: | 'unknown' 95: export type ClaudeAILimits = { 96: status: QuotaStatus 97: unifiedRateLimitFallbackAvailable: boolean 98: resetsAt?: number 99: rateLimitType?: RateLimitType 100: utilization?: number 101: overageStatus?: QuotaStatus 102: overageResetsAt?: number 103: overageDisabledReason?: OverageDisabledReason 104: isUsingOverage?: boolean 105: surpassedThreshold?: number 106: } 107: export let currentLimits: ClaudeAILimits = { 108: status: 'allowed', 109: unifiedRateLimitFallbackAvailable: false, 110: isUsingOverage: false, 111: } 112: type RawWindowUtilization = { 113: utilization: number 114: resets_at: number 115: } 116: type RawUtilization = { 117: five_hour?: RawWindowUtilization 118: seven_day?: RawWindowUtilization 119: } 120: let rawUtilization: RawUtilization = {} 121: export function getRawUtilization(): RawUtilization { 122: return rawUtilization 123: } 124: function extractRawUtilization(headers: globalThis.Headers): RawUtilization { 125: const result: RawUtilization = {} 126: for (const [key, abbrev] of [ 127: ['five_hour', '5h'], 128: ['seven_day', '7d'], 129: ] as const) { 130: const util = headers.get( 131: `anthropic-ratelimit-unified-${abbrev}-utilization`, 132: ) 133: const reset = headers.get(`anthropic-ratelimit-unified-${abbrev}-reset`) 134: if (util !== null && reset !== null) { 135: result[key] = { utilization: Number(util), resets_at: Number(reset) } 136: } 137: } 138: return result 139: } 140: type StatusChangeListener = (limits: ClaudeAILimits) => void 141: export const statusListeners: Set<StatusChangeListener> = new Set() 142: export function emitStatusChange(limits: ClaudeAILimits) { 143: currentLimits = limits 144: statusListeners.forEach(listener => listener(limits)) 145: const hoursTillReset = Math.round( 146: (limits.resetsAt ? limits.resetsAt - Date.now() / 1000 : 0) / (60 * 60), 147: ) 148: logEvent('tengu_claudeai_limits_status_changed', { 149: status: 150: limits.status as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 151: unifiedRateLimitFallbackAvailable: limits.unifiedRateLimitFallbackAvailable, 152: hoursTillReset, 153: }) 154: } 155: async function makeTestQuery() { 156: const model = getSmallFastModel() 157: const anthropic = await getAnthropicClient({ 158: maxRetries: 0, 159: model, 160: source: 'quota_check', 161: }) 162: const messages: MessageParam[] = [{ role: 'user', content: 'quota' }] 163: const betas = getModelBetas(model) 164: return anthropic.beta.messages 165: .create({ 166: model, 167: max_tokens: 1, 168: messages, 169: metadata: getAPIMetadata(), 170: ...(betas.length > 0 ? { betas } : {}), 171: }) 172: .asResponse() 173: } 174: export async function checkQuotaStatus(): Promise<void> { 175: if (isEssentialTrafficOnly()) { 176: return 177: } 178: if (!shouldProcessRateLimits(isClaudeAISubscriber())) { 179: return 180: } 181: if (getIsNonInteractiveSession()) { 182: return 183: } 184: try { 185: const raw = await makeTestQuery() 186: extractQuotaStatusFromHeaders(raw.headers) 187: } catch (error) { 188: if (error instanceof APIError) { 189: extractQuotaStatusFromError(error) 190: } 191: } 192: } 193: function getHeaderBasedEarlyWarning( 194: headers: globalThis.Headers, 195: unifiedRateLimitFallbackAvailable: boolean, 196: ): ClaudeAILimits | null { 197: for (const [claimAbbrev, rateLimitType] of Object.entries( 198: EARLY_WARNING_CLAIM_MAP, 199: )) { 200: const surpassedThreshold = headers.get( 201: `anthropic-ratelimit-unified-${claimAbbrev}-surpassed-threshold`, 202: ) 203: if (surpassedThreshold !== null) { 204: const utilizationHeader = headers.get( 205: `anthropic-ratelimit-unified-${claimAbbrev}-utilization`, 206: ) 207: const resetHeader = headers.get( 208: `anthropic-ratelimit-unified-${claimAbbrev}-reset`, 209: ) 210: const utilization = utilizationHeader 211: ? Number(utilizationHeader) 212: : undefined 213: const resetsAt = resetHeader ? Number(resetHeader) : undefined 214: return { 215: status: 'allowed_warning', 216: resetsAt, 217: rateLimitType: rateLimitType as RateLimitType, 218: utilization, 219: unifiedRateLimitFallbackAvailable, 220: isUsingOverage: false, 221: surpassedThreshold: Number(surpassedThreshold), 222: } 223: } 224: } 225: return null 226: } 227: function getTimeRelativeEarlyWarning( 228: headers: globalThis.Headers, 229: config: EarlyWarningConfig, 230: unifiedRateLimitFallbackAvailable: boolean, 231: ): ClaudeAILimits | null { 232: const { rateLimitType, claimAbbrev, windowSeconds, thresholds } = config 233: const utilizationHeader = headers.get( 234: `anthropic-ratelimit-unified-${claimAbbrev}-utilization`, 235: ) 236: const resetHeader = headers.get( 237: `anthropic-ratelimit-unified-${claimAbbrev}-reset`, 238: ) 239: if (utilizationHeader === null || resetHeader === null) { 240: return null 241: } 242: const utilization = Number(utilizationHeader) 243: const resetsAt = Number(resetHeader) 244: const timeProgress = computeTimeProgress(resetsAt, windowSeconds) 245: const shouldWarn = thresholds.some( 246: t => utilization >= t.utilization && timeProgress <= t.timePct, 247: ) 248: if (!shouldWarn) { 249: return null 250: } 251: return { 252: status: 'allowed_warning', 253: resetsAt, 254: rateLimitType, 255: utilization, 256: unifiedRateLimitFallbackAvailable, 257: isUsingOverage: false, 258: } 259: } 260: function getEarlyWarningFromHeaders( 261: headers: globalThis.Headers, 262: unifiedRateLimitFallbackAvailable: boolean, 263: ): ClaudeAILimits | null { 264: const headerBasedWarning = getHeaderBasedEarlyWarning( 265: headers, 266: unifiedRateLimitFallbackAvailable, 267: ) 268: if (headerBasedWarning) { 269: return headerBasedWarning 270: } 271: for (const config of EARLY_WARNING_CONFIGS) { 272: const timeRelativeWarning = getTimeRelativeEarlyWarning( 273: headers, 274: config, 275: unifiedRateLimitFallbackAvailable, 276: ) 277: if (timeRelativeWarning) { 278: return timeRelativeWarning 279: } 280: } 281: return null 282: } 283: function computeNewLimitsFromHeaders( 284: headers: globalThis.Headers, 285: ): ClaudeAILimits { 286: const status = 287: (headers.get('anthropic-ratelimit-unified-status') as QuotaStatus) || 288: 'allowed' 289: const resetsAtHeader = headers.get('anthropic-ratelimit-unified-reset') 290: const resetsAt = resetsAtHeader ? Number(resetsAtHeader) : undefined 291: const unifiedRateLimitFallbackAvailable = 292: headers.get('anthropic-ratelimit-unified-fallback') === 'available' 293: const rateLimitType = headers.get( 294: 'anthropic-ratelimit-unified-representative-claim', 295: ) as RateLimitType | null 296: const overageStatus = headers.get( 297: 'anthropic-ratelimit-unified-overage-status', 298: ) as QuotaStatus | null 299: const overageResetsAtHeader = headers.get( 300: 'anthropic-ratelimit-unified-overage-reset', 301: ) 302: const overageResetsAt = overageResetsAtHeader 303: ? Number(overageResetsAtHeader) 304: : undefined 305: const overageDisabledReason = headers.get( 306: 'anthropic-ratelimit-unified-overage-disabled-reason', 307: ) as OverageDisabledReason | null 308: const isUsingOverage = 309: status === 'rejected' && 310: (overageStatus === 'allowed' || overageStatus === 'allowed_warning') 311: let finalStatus: QuotaStatus = status 312: if (status === 'allowed' || status === 'allowed_warning') { 313: const earlyWarning = getEarlyWarningFromHeaders( 314: headers, 315: unifiedRateLimitFallbackAvailable, 316: ) 317: if (earlyWarning) { 318: return earlyWarning 319: } 320: finalStatus = 'allowed' 321: } 322: return { 323: status: finalStatus, 324: resetsAt, 325: unifiedRateLimitFallbackAvailable, 326: ...(rateLimitType && { rateLimitType }), 327: ...(overageStatus && { overageStatus }), 328: ...(overageResetsAt && { overageResetsAt }), 329: ...(overageDisabledReason && { overageDisabledReason }), 330: isUsingOverage, 331: } 332: } 333: function cacheExtraUsageDisabledReason(headers: globalThis.Headers): void { 334: const reason = 335: headers.get('anthropic-ratelimit-unified-overage-disabled-reason') ?? null 336: const cached = getGlobalConfig().cachedExtraUsageDisabledReason 337: if (cached !== reason) { 338: saveGlobalConfig(current => ({ 339: ...current, 340: cachedExtraUsageDisabledReason: reason, 341: })) 342: } 343: } 344: export function extractQuotaStatusFromHeaders( 345: headers: globalThis.Headers, 346: ): void { 347: const isSubscriber = isClaudeAISubscriber() 348: if (!shouldProcessRateLimits(isSubscriber)) { 349: rawUtilization = {} 350: if (currentLimits.status !== 'allowed' || currentLimits.resetsAt) { 351: const defaultLimits: ClaudeAILimits = { 352: status: 'allowed', 353: unifiedRateLimitFallbackAvailable: false, 354: isUsingOverage: false, 355: } 356: emitStatusChange(defaultLimits) 357: } 358: return 359: } 360: const headersToUse = processRateLimitHeaders(headers) 361: rawUtilization = extractRawUtilization(headersToUse) 362: const newLimits = computeNewLimitsFromHeaders(headersToUse) 363: cacheExtraUsageDisabledReason(headersToUse) 364: if (!isEqual(currentLimits, newLimits)) { 365: emitStatusChange(newLimits) 366: } 367: } 368: export function extractQuotaStatusFromError(error: APIError): void { 369: if ( 370: !shouldProcessRateLimits(isClaudeAISubscriber()) || 371: error.status !== 429 372: ) { 373: return 374: } 375: try { 376: let newLimits = { ...currentLimits } 377: if (error.headers) { 378: const headersToUse = processRateLimitHeaders(error.headers) 379: rawUtilization = extractRawUtilization(headersToUse) 380: newLimits = computeNewLimitsFromHeaders(headersToUse) 381: cacheExtraUsageDisabledReason(headersToUse) 382: } 383: newLimits.status = 'rejected' 384: if (!isEqual(currentLimits, newLimits)) { 385: emitStatusChange(newLimits) 386: } 387: } catch (e) { 388: logError(e as Error) 389: } 390: }

File: src/services/claudeAiLimitsHook.ts

typescript 1: import { useEffect, useState } from 'react' 2: import { 3: type ClaudeAILimits, 4: currentLimits, 5: statusListeners, 6: } from './claudeAiLimits.js' 7: export function useClaudeAiLimits(): ClaudeAILimits { 8: const [limits, setLimits] = useState<ClaudeAILimits>({ ...currentLimits }) 9: useEffect(() => { 10: const listener = (newLimits: ClaudeAILimits) => { 11: setLimits({ ...newLimits }) 12: } 13: statusListeners.add(listener) 14: return () => { 15: statusListeners.delete(listener) 16: } 17: }, []) 18: return limits 19: }

File: src/services/diagnosticTracking.ts

typescript 1: import figures from 'figures' 2: import { logError } from 'src/utils/log.js' 3: import { callIdeRpc } from '../services/mcp/client.js' 4: import type { MCPServerConnection } from '../services/mcp/types.js' 5: import { ClaudeError } from '../utils/errors.js' 6: import { normalizePathForComparison, pathsEqual } from '../utils/file.js' 7: import { getConnectedIdeClient } from '../utils/ide.js' 8: import { jsonParse } from '../utils/slowOperations.js' 9: class DiagnosticsTrackingError extends ClaudeError {} 10: const MAX_DIAGNOSTICS_SUMMARY_CHARS = 4000 11: export interface Diagnostic { 12: message: string 13: severity: 'Error' | 'Warning' | 'Info' | 'Hint' 14: range: { 15: start: { line: number; character: number } 16: end: { line: number; character: number } 17: } 18: source?: string 19: code?: string 20: } 21: export interface DiagnosticFile { 22: uri: string 23: diagnostics: Diagnostic[] 24: } 25: export class DiagnosticTrackingService { 26: private static instance: DiagnosticTrackingService | undefined 27: private baseline: Map<string, Diagnostic[]> = new Map() 28: private initialized = false 29: private mcpClient: MCPServerConnection | undefined 30: private lastProcessedTimestamps: Map<string, number> = new Map() 31: private rightFileDiagnosticsState: Map<string, Diagnostic[]> = new Map() 32: static getInstance(): DiagnosticTrackingService { 33: if (!DiagnosticTrackingService.instance) { 34: DiagnosticTrackingService.instance = new DiagnosticTrackingService() 35: } 36: return DiagnosticTrackingService.instance 37: } 38: initialize(mcpClient: MCPServerConnection) { 39: if (this.initialized) { 40: return 41: } 42: this.mcpClient = mcpClient 43: this.initialized = true 44: } 45: async shutdown(): Promise<void> { 46: this.initialized = false 47: this.baseline.clear() 48: this.rightFileDiagnosticsState.clear() 49: this.lastProcessedTimestamps.clear() 50: } 51: reset() { 52: this.baseline.clear() 53: this.rightFileDiagnosticsState.clear() 54: this.lastProcessedTimestamps.clear() 55: } 56: private normalizeFileUri(fileUri: string): string { 57: const protocolPrefixes = [ 58: 'file://', 59: '_claude_fs_right:', 60: '_claude_fs_left:', 61: ] 62: let normalized = fileUri 63: for (const prefix of protocolPrefixes) { 64: if (fileUri.startsWith(prefix)) { 65: normalized = fileUri.slice(prefix.length) 66: break 67: } 68: } 69: return normalizePathForComparison(normalized) 70: } 71: async ensureFileOpened(fileUri: string): Promise<void> { 72: if ( 73: !this.initialized || 74: !this.mcpClient || 75: this.mcpClient.type !== 'connected' 76: ) { 77: return 78: } 79: try { 80: await callIdeRpc( 81: 'openFile', 82: { 83: filePath: fileUri, 84: preview: false, 85: startText: '', 86: endText: '', 87: selectToEndOfLine: false, 88: makeFrontmost: false, 89: }, 90: this.mcpClient, 91: ) 92: } catch (error) { 93: logError(error as Error) 94: } 95: } 96: /** 97: * Capture baseline diagnostics for a specific file before editing. 98: * This is called before editing a file to ensure we have a baseline to compare against. 99: */ 100: async beforeFileEdited(filePath: string): Promise<void> { 101: if ( 102: !this.initialized || 103: !this.mcpClient || 104: this.mcpClient.type !== 'connected' 105: ) { 106: return 107: } 108: const timestamp = Date.now() 109: try { 110: const result = await callIdeRpc( 111: 'getDiagnostics', 112: { uri: `file://${filePath}` }, 113: this.mcpClient, 114: ) 115: const diagnosticFile = this.parseDiagnosticResult(result)[0] 116: if (diagnosticFile) { 117: if ( 118: !pathsEqual( 119: this.normalizeFileUri(filePath), 120: this.normalizeFileUri(diagnosticFile.uri), 121: ) 122: ) { 123: logError( 124: new DiagnosticsTrackingError( 125: `Diagnostics file path mismatch: expected ${filePath}, got ${diagnosticFile.uri})`, 126: ), 127: ) 128: return 129: } 130: const normalizedPath = this.normalizeFileUri(filePath) 131: this.baseline.set(normalizedPath, diagnosticFile.diagnostics) 132: this.lastProcessedTimestamps.set(normalizedPath, timestamp) 133: } else { 134: const normalizedPath = this.normalizeFileUri(filePath) 135: this.baseline.set(normalizedPath, []) 136: this.lastProcessedTimestamps.set(normalizedPath, timestamp) 137: } 138: } catch (_error) { 139: } 140: } 141: async getNewDiagnostics(): Promise<DiagnosticFile[]> { 142: if ( 143: !this.initialized || 144: !this.mcpClient || 145: this.mcpClient.type !== 'connected' 146: ) { 147: return [] 148: } 149: let allDiagnosticFiles: DiagnosticFile[] = [] 150: try { 151: const result = await callIdeRpc( 152: 'getDiagnostics', 153: {}, 154: this.mcpClient, 155: ) 156: allDiagnosticFiles = this.parseDiagnosticResult(result) 157: } catch (_error) { 158: return [] 159: } 160: const diagnosticsForFileUrisWithBaselines = allDiagnosticFiles 161: .filter(file => this.baseline.has(this.normalizeFileUri(file.uri))) 162: .filter(file => file.uri.startsWith('file://')) 163: const diagnosticsForClaudeFsRightUrisWithBaselinesMap = new Map< 164: string, 165: DiagnosticFile 166: >() 167: allDiagnosticFiles 168: .filter(file => this.baseline.has(this.normalizeFileUri(file.uri))) 169: .filter(file => file.uri.startsWith('_claude_fs_right:')) 170: .forEach(file => { 171: diagnosticsForClaudeFsRightUrisWithBaselinesMap.set( 172: this.normalizeFileUri(file.uri), 173: file, 174: ) 175: }) 176: const newDiagnosticFiles: DiagnosticFile[] = [] 177: for (const file of diagnosticsForFileUrisWithBaselines) { 178: const normalizedPath = this.normalizeFileUri(file.uri) 179: const baselineDiagnostics = this.baseline.get(normalizedPath) || [] 180: const claudeFsRightFile = 181: diagnosticsForClaudeFsRightUrisWithBaselinesMap.get(normalizedPath) 182: let fileToUse = file 183: if (claudeFsRightFile) { 184: const previousRightDiagnostics = 185: this.rightFileDiagnosticsState.get(normalizedPath) 186: if ( 187: !previousRightDiagnostics || 188: !this.areDiagnosticArraysEqual( 189: previousRightDiagnostics, 190: claudeFsRightFile.diagnostics, 191: ) 192: ) { 193: fileToUse = claudeFsRightFile 194: } 195: this.rightFileDiagnosticsState.set( 196: normalizedPath, 197: claudeFsRightFile.diagnostics, 198: ) 199: } 200: const newDiagnostics = fileToUse.diagnostics.filter( 201: d => !baselineDiagnostics.some(b => this.areDiagnosticsEqual(d, b)), 202: ) 203: if (newDiagnostics.length > 0) { 204: newDiagnosticFiles.push({ 205: uri: file.uri, 206: diagnostics: newDiagnostics, 207: }) 208: } 209: this.baseline.set(normalizedPath, fileToUse.diagnostics) 210: } 211: return newDiagnosticFiles 212: } 213: private parseDiagnosticResult(result: unknown): DiagnosticFile[] { 214: if (Array.isArray(result)) { 215: const textBlock = result.find(block => block.type === 'text') 216: if (textBlock && 'text' in textBlock) { 217: const parsed = jsonParse(textBlock.text) 218: return parsed 219: } 220: } 221: return [] 222: } 223: private areDiagnosticsEqual(a: Diagnostic, b: Diagnostic): boolean { 224: return ( 225: a.message === b.message && 226: a.severity === b.severity && 227: a.source === b.source && 228: a.code === b.code && 229: a.range.start.line === b.range.start.line && 230: a.range.start.character === b.range.start.character && 231: a.range.end.line === b.range.end.line && 232: a.range.end.character === b.range.end.character 233: ) 234: } 235: private areDiagnosticArraysEqual(a: Diagnostic[], b: Diagnostic[]): boolean { 236: if (a.length !== b.length) return false 237: return ( 238: a.every(diagA => 239: b.some(diagB => this.areDiagnosticsEqual(diagA, diagB)), 240: ) && 241: b.every(diagB => a.some(diagA => this.areDiagnosticsEqual(diagA, diagB))) 242: ) 243: } 244: async handleQueryStart(clients: MCPServerConnection[]): Promise<void> { 245: if (!this.initialized) { 246: const connectedIdeClient = getConnectedIdeClient(clients) 247: if (connectedIdeClient) { 248: this.initialize(connectedIdeClient) 249: } 250: } else { 251: this.reset() 252: } 253: } 254: static formatDiagnosticsSummary(files: DiagnosticFile[]): string { 255: const truncationMarker = '…[truncated]' 256: const result = files 257: .map(file => { 258: const filename = file.uri.split('/').pop() || file.uri 259: const diagnostics = file.diagnostics 260: .map(d => { 261: const severitySymbol = DiagnosticTrackingService.getSeveritySymbol( 262: d.severity, 263: ) 264: return ` ${severitySymbol} [Line ${d.range.start.line + 1}:${d.range.start.character + 1}] ${d.message}${d.code ? ` [${d.code}]` : ''}${d.source ? ` (${d.source})` : ''}` 265: }) 266: .join('\n') 267: return `${filename}:\n${diagnostics}` 268: }) 269: .join('\n\n') 270: if (result.length > MAX_DIAGNOSTICS_SUMMARY_CHARS) { 271: return ( 272: result.slice( 273: 0, 274: MAX_DIAGNOSTICS_SUMMARY_CHARS - truncationMarker.length, 275: ) + truncationMarker 276: ) 277: } 278: return result 279: } 280: static getSeveritySymbol(severity: Diagnostic['severity']): string { 281: return ( 282: { 283: Error: figures.cross, 284: Warning: figures.warning, 285: Info: figures.info, 286: Hint: figures.star, 287: }[severity] || figures.bullet 288: ) 289: } 290: } 291: export const diagnosticTracker = DiagnosticTrackingService.getInstance()

File: src/services/internalLogging.ts

typescript 1: import { readFile } from 'fs/promises' 2: import memoize from 'lodash-es/memoize.js' 3: import type { ToolPermissionContext } from '../Tool.js' 4: import { jsonStringify } from '../utils/slowOperations.js' 5: import { 6: type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 7: logEvent, 8: } from './analytics/index.js' 9: const getKubernetesNamespace = memoize(async (): Promise<string | null> => { 10: if (process.env.USER_TYPE !== 'ant') { 11: return null 12: } 13: const namespacePath = 14: '/var/run/secrets/kubernetes.io/serviceaccount/namespace' 15: const namespaceNotFound = 'namespace not found' 16: try { 17: const content = await readFile(namespacePath, { encoding: 'utf8' }) 18: return content.trim() 19: } catch { 20: return namespaceNotFound 21: } 22: }) 23: export const getContainerId = memoize(async (): Promise<string | null> => { 24: if (process.env.USER_TYPE !== 'ant') { 25: return null 26: } 27: const containerIdPath = '/proc/self/mountinfo' 28: const containerIdNotFound = 'container ID not found' 29: const containerIdNotFoundInMountinfo = 'container ID not found in mountinfo' 30: try { 31: const mountinfo = ( 32: await readFile(containerIdPath, { encoding: 'utf8' }) 33: ).trim() 34: const containerIdPattern = 35: /(?:\/docker\/containers\/|\/sandboxes\/)([0-9a-f]{64})/ 36: const lines = mountinfo.split('\n') 37: for (const line of lines) { 38: const match = line.match(containerIdPattern) 39: if (match && match[1]) { 40: return match[1] 41: } 42: } 43: return containerIdNotFoundInMountinfo 44: } catch { 45: return containerIdNotFound 46: } 47: }) 48: export async function logPermissionContextForAnts( 49: toolPermissionContext: ToolPermissionContext | null, 50: moment: 'summary' | 'initialization', 51: ): Promise<void> { 52: if (process.env.USER_TYPE !== 'ant') { 53: return 54: } 55: void logEvent('tengu_internal_record_permission_context', { 56: moment: 57: moment as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 58: namespace: 59: (await getKubernetesNamespace()) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 60: toolPermissionContext: jsonStringify( 61: toolPermissionContext, 62: ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 63: containerId: 64: (await getContainerId()) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 65: }) 66: }

File: src/services/mcpServerApproval.tsx

typescript 1: import React from 'react'; 2: import { MCPServerApprovalDialog } from '../components/MCPServerApprovalDialog.js'; 3: import { MCPServerMultiselectDialog } from '../components/MCPServerMultiselectDialog.js'; 4: import type { Root } from '../ink.js'; 5: import { KeybindingSetup } from '../keybindings/KeybindingProviderSetup.js'; 6: import { AppStateProvider } from '../state/AppState.js'; 7: import { getMcpConfigsByScope } from './mcp/config.js'; 8: import { getProjectMcpServerStatus } from './mcp/utils.js'; 9: export async function handleMcpjsonServerApprovals(root: Root): Promise<void> { 10: const { 11: servers: projectServers 12: } = getMcpConfigsByScope('project'); 13: const pendingServers = Object.keys(projectServers).filter(serverName => getProjectMcpServerStatus(serverName) === 'pending'); 14: if (pendingServers.length === 0) { 15: return; 16: } 17: await new Promise<void>(resolve => { 18: const done = (): void => void resolve(); 19: if (pendingServers.length === 1 && pendingServers[0] !== undefined) { 20: const serverName = pendingServers[0]; 21: root.render(<AppStateProvider> 22: <KeybindingSetup> 23: <MCPServerApprovalDialog serverName={serverName} onDone={done} /> 24: </KeybindingSetup> 25: </AppStateProvider>); 26: } else { 27: root.render(<AppStateProvider> 28: <KeybindingSetup> 29: <MCPServerMultiselectDialog serverNames={pendingServers} onDone={done} /> 30: </KeybindingSetup> 31: </AppStateProvider>); 32: } 33: }); 34: }

File: src/services/mockRateLimits.ts

typescript 1: import type { SubscriptionType } from '../services/oauth/types.js' 2: import { setMockBillingAccessOverride } from '../utils/billing.js' 3: import type { OverageDisabledReason } from './claudeAiLimits.js' 4: type MockHeaders = { 5: 'anthropic-ratelimit-unified-status'?: 6: | 'allowed' 7: | 'allowed_warning' 8: | 'rejected' 9: 'anthropic-ratelimit-unified-reset'?: string 10: 'anthropic-ratelimit-unified-representative-claim'?: 11: | 'five_hour' 12: | 'seven_day' 13: | 'seven_day_opus' 14: | 'seven_day_sonnet' 15: 'anthropic-ratelimit-unified-overage-status'?: 16: | 'allowed' 17: | 'allowed_warning' 18: | 'rejected' 19: 'anthropic-ratelimit-unified-overage-reset'?: string 20: 'anthropic-ratelimit-unified-overage-disabled-reason'?: OverageDisabledReason 21: 'anthropic-ratelimit-unified-fallback'?: 'available' 22: 'anthropic-ratelimit-unified-fallback-percentage'?: string 23: 'retry-after'?: string 24: 'anthropic-ratelimit-unified-5h-utilization'?: string 25: 'anthropic-ratelimit-unified-5h-reset'?: string 26: 'anthropic-ratelimit-unified-5h-surpassed-threshold'?: string 27: 'anthropic-ratelimit-unified-7d-utilization'?: string 28: 'anthropic-ratelimit-unified-7d-reset'?: string 29: 'anthropic-ratelimit-unified-7d-surpassed-threshold'?: string 30: 'anthropic-ratelimit-unified-overage-utilization'?: string 31: 'anthropic-ratelimit-unified-overage-surpassed-threshold'?: string 32: } 33: export type MockHeaderKey = 34: | 'status' 35: | 'reset' 36: | 'claim' 37: | 'overage-status' 38: | 'overage-reset' 39: | 'overage-disabled-reason' 40: | 'fallback' 41: | 'fallback-percentage' 42: | 'retry-after' 43: | '5h-utilization' 44: | '5h-reset' 45: | '5h-surpassed-threshold' 46: | '7d-utilization' 47: | '7d-reset' 48: | '7d-surpassed-threshold' 49: export type MockScenario = 50: | 'normal' 51: | 'session-limit-reached' 52: | 'approaching-weekly-limit' 53: | 'weekly-limit-reached' 54: | 'overage-active' 55: | 'overage-warning' 56: | 'overage-exhausted' 57: | 'out-of-credits' 58: | 'org-zero-credit-limit' 59: | 'org-spend-cap-hit' 60: | 'member-zero-credit-limit' 61: | 'seat-tier-zero-credit-limit' 62: | 'opus-limit' 63: | 'opus-warning' 64: | 'sonnet-limit' 65: | 'sonnet-warning' 66: | 'fast-mode-limit' 67: | 'fast-mode-short-limit' 68: | 'extra-usage-required' 69: | 'clear' 70: let mockHeaders: MockHeaders = {} 71: let mockEnabled = false 72: let mockHeaderless429Message: string | null = null 73: let mockSubscriptionType: SubscriptionType | null = null 74: let mockFastModeRateLimitDurationMs: number | null = null 75: let mockFastModeRateLimitExpiresAt: number | null = null 76: const DEFAULT_MOCK_SUBSCRIPTION: SubscriptionType = 'max' 77: type ExceededLimit = { 78: type: 'five_hour' | 'seven_day' | 'seven_day_opus' | 'seven_day_sonnet' 79: resetsAt: number 80: } 81: let exceededLimits: ExceededLimit[] = [] 82: export function setMockHeader( 83: key: MockHeaderKey, 84: value: string | undefined, 85: ): void { 86: if (process.env.USER_TYPE !== 'ant') { 87: return 88: } 89: mockEnabled = true 90: const fullKey = ( 91: key === 'retry-after' ? 'retry-after' : `anthropic-ratelimit-unified-${key}` 92: ) as keyof MockHeaders 93: if (value === undefined || value === 'clear') { 94: delete mockHeaders[fullKey] 95: if (key === 'claim') { 96: exceededLimits = [] 97: } 98: if (key === 'status' || key === 'overage-status') { 99: updateRetryAfter() 100: } 101: return 102: } else { 103: if (key === 'reset' || key === 'overage-reset') { 104: const hours = Number(value) 105: if (!isNaN(hours)) { 106: value = String(Math.floor(Date.now() / 1000) + hours * 3600) 107: } 108: } 109: if (key === 'claim') { 110: const validClaims = [ 111: 'five_hour', 112: 'seven_day', 113: 'seven_day_opus', 114: 'seven_day_sonnet', 115: ] 116: if (validClaims.includes(value)) { 117: let resetsAt: number 118: if (value === 'five_hour') { 119: resetsAt = Math.floor(Date.now() / 1000) + 5 * 3600 120: } else if ( 121: value === 'seven_day' || 122: value === 'seven_day_opus' || 123: value === 'seven_day_sonnet' 124: ) { 125: resetsAt = Math.floor(Date.now() / 1000) + 7 * 24 * 3600 126: } else { 127: resetsAt = Math.floor(Date.now() / 1000) + 3600 128: } 129: exceededLimits = exceededLimits.filter(l => l.type !== value) 130: exceededLimits.push({ type: value as ExceededLimit['type'], resetsAt }) 131: updateRepresentativeClaim() 132: return 133: } 134: } 135: const headers: Partial<Record<keyof MockHeaders, string>> = mockHeaders 136: headers[fullKey] = value 137: if (key === 'status' || key === 'overage-status') { 138: updateRetryAfter() 139: } 140: } 141: if (Object.keys(mockHeaders).length === 0) { 142: mockEnabled = false 143: } 144: } 145: function updateRetryAfter(): void { 146: const status = mockHeaders['anthropic-ratelimit-unified-status'] 147: const overageStatus = 148: mockHeaders['anthropic-ratelimit-unified-overage-status'] 149: const reset = mockHeaders['anthropic-ratelimit-unified-reset'] 150: if ( 151: status === 'rejected' && 152: (!overageStatus || overageStatus === 'rejected') && 153: reset 154: ) { 155: const resetTimestamp = Number(reset) 156: const secondsUntilReset = Math.max( 157: 0, 158: resetTimestamp - Math.floor(Date.now() / 1000), 159: ) 160: mockHeaders['retry-after'] = String(secondsUntilReset) 161: } else { 162: delete mockHeaders['retry-after'] 163: } 164: } 165: function updateRepresentativeClaim(): void { 166: if (exceededLimits.length === 0) { 167: delete mockHeaders['anthropic-ratelimit-unified-representative-claim'] 168: delete mockHeaders['anthropic-ratelimit-unified-reset'] 169: delete mockHeaders['retry-after'] 170: return 171: } 172: const furthest = exceededLimits.reduce((prev, curr) => 173: curr.resetsAt > prev.resetsAt ? curr : prev, 174: ) 175: mockHeaders['anthropic-ratelimit-unified-representative-claim'] = 176: furthest.type 177: mockHeaders['anthropic-ratelimit-unified-reset'] = String(furthest.resetsAt) 178: if (mockHeaders['anthropic-ratelimit-unified-status'] === 'rejected') { 179: const overageStatus = 180: mockHeaders['anthropic-ratelimit-unified-overage-status'] 181: if (!overageStatus || overageStatus === 'rejected') { 182: const secondsUntilReset = Math.max( 183: 0, 184: furthest.resetsAt - Math.floor(Date.now() / 1000), 185: ) 186: mockHeaders['retry-after'] = String(secondsUntilReset) 187: } else { 188: delete mockHeaders['retry-after'] 189: } 190: } else { 191: delete mockHeaders['retry-after'] 192: } 193: } 194: export function addExceededLimit( 195: type: 'five_hour' | 'seven_day' | 'seven_day_opus' | 'seven_day_sonnet', 196: hoursFromNow: number, 197: ): void { 198: if (process.env.USER_TYPE !== 'ant') { 199: return 200: } 201: mockEnabled = true 202: const resetsAt = Math.floor(Date.now() / 1000) + hoursFromNow * 3600 203: exceededLimits = exceededLimits.filter(l => l.type !== type) 204: exceededLimits.push({ type, resetsAt }) 205: if (exceededLimits.length > 0) { 206: mockHeaders['anthropic-ratelimit-unified-status'] = 'rejected' 207: } 208: updateRepresentativeClaim() 209: } 210: export function setMockEarlyWarning( 211: claimAbbrev: '5h' | '7d' | 'overage', 212: utilization: number, 213: hoursFromNow?: number, 214: ): void { 215: if (process.env.USER_TYPE !== 'ant') { 216: return 217: } 218: mockEnabled = true 219: clearMockEarlyWarning() 220: const defaultHours = claimAbbrev === '5h' ? 4 : 5 * 24 221: const hours = hoursFromNow ?? defaultHours 222: const resetsAt = Math.floor(Date.now() / 1000) + hours * 3600 223: mockHeaders[`anthropic-ratelimit-unified-${claimAbbrev}-utilization`] = 224: String(utilization) 225: mockHeaders[`anthropic-ratelimit-unified-${claimAbbrev}-reset`] = 226: String(resetsAt) 227: mockHeaders[ 228: `anthropic-ratelimit-unified-${claimAbbrev}-surpassed-threshold` 229: ] = String(utilization) 230: if (!mockHeaders['anthropic-ratelimit-unified-status']) { 231: mockHeaders['anthropic-ratelimit-unified-status'] = 'allowed' 232: } 233: } 234: export function clearMockEarlyWarning(): void { 235: delete mockHeaders['anthropic-ratelimit-unified-5h-utilization'] 236: delete mockHeaders['anthropic-ratelimit-unified-5h-reset'] 237: delete mockHeaders['anthropic-ratelimit-unified-5h-surpassed-threshold'] 238: delete mockHeaders['anthropic-ratelimit-unified-7d-utilization'] 239: delete mockHeaders['anthropic-ratelimit-unified-7d-reset'] 240: delete mockHeaders['anthropic-ratelimit-unified-7d-surpassed-threshold'] 241: } 242: export function setMockRateLimitScenario(scenario: MockScenario): void { 243: if (process.env.USER_TYPE !== 'ant') { 244: return 245: } 246: if (scenario === 'clear') { 247: mockHeaders = {} 248: mockHeaderless429Message = null 249: mockEnabled = false 250: return 251: } 252: mockEnabled = true 253: const fiveHoursFromNow = Math.floor(Date.now() / 1000) + 5 * 3600 254: const sevenDaysFromNow = Math.floor(Date.now() / 1000) + 7 * 24 * 3600 255: mockHeaders = {} 256: mockHeaderless429Message = null 257: const preserveExceededLimits = [ 258: 'overage-active', 259: 'overage-warning', 260: 'overage-exhausted', 261: ].includes(scenario) 262: if (!preserveExceededLimits) { 263: exceededLimits = [] 264: } 265: switch (scenario) { 266: case 'normal': 267: mockHeaders = { 268: 'anthropic-ratelimit-unified-status': 'allowed', 269: 'anthropic-ratelimit-unified-reset': String(fiveHoursFromNow), 270: } 271: break 272: case 'session-limit-reached': 273: exceededLimits = [{ type: 'five_hour', resetsAt: fiveHoursFromNow }] 274: updateRepresentativeClaim() 275: mockHeaders['anthropic-ratelimit-unified-status'] = 'rejected' 276: break 277: case 'approaching-weekly-limit': 278: mockHeaders = { 279: 'anthropic-ratelimit-unified-status': 'allowed_warning', 280: 'anthropic-ratelimit-unified-reset': String(sevenDaysFromNow), 281: 'anthropic-ratelimit-unified-representative-claim': 'seven_day', 282: } 283: break 284: case 'weekly-limit-reached': 285: exceededLimits = [{ type: 'seven_day', resetsAt: sevenDaysFromNow }] 286: updateRepresentativeClaim() 287: mockHeaders['anthropic-ratelimit-unified-status'] = 'rejected' 288: break 289: case 'overage-active': { 290: if (exceededLimits.length === 0) { 291: exceededLimits = [{ type: 'five_hour', resetsAt: fiveHoursFromNow }] 292: } 293: updateRepresentativeClaim() 294: mockHeaders['anthropic-ratelimit-unified-status'] = 'rejected' 295: mockHeaders['anthropic-ratelimit-unified-overage-status'] = 'allowed' 296: const endOfMonthActive = new Date() 297: endOfMonthActive.setMonth(endOfMonthActive.getMonth() + 1, 1) 298: endOfMonthActive.setHours(0, 0, 0, 0) 299: mockHeaders['anthropic-ratelimit-unified-overage-reset'] = String( 300: Math.floor(endOfMonthActive.getTime() / 1000), 301: ) 302: break 303: } 304: case 'overage-warning': { 305: if (exceededLimits.length === 0) { 306: exceededLimits = [{ type: 'five_hour', resetsAt: fiveHoursFromNow }] 307: } 308: updateRepresentativeClaim() 309: mockHeaders['anthropic-ratelimit-unified-status'] = 'rejected' 310: mockHeaders['anthropic-ratelimit-unified-overage-status'] = 311: 'allowed_warning' 312: const endOfMonth = new Date() 313: endOfMonth.setMonth(endOfMonth.getMonth() + 1, 1) 314: endOfMonth.setHours(0, 0, 0, 0) 315: mockHeaders['anthropic-ratelimit-unified-overage-reset'] = String( 316: Math.floor(endOfMonth.getTime() / 1000), 317: ) 318: break 319: } 320: case 'overage-exhausted': { 321: if (exceededLimits.length === 0) { 322: exceededLimits = [{ type: 'five_hour', resetsAt: fiveHoursFromNow }] 323: } 324: updateRepresentativeClaim() 325: mockHeaders['anthropic-ratelimit-unified-status'] = 'rejected' 326: mockHeaders['anthropic-ratelimit-unified-overage-status'] = 'rejected' 327: const endOfMonthExhausted = new Date() 328: endOfMonthExhausted.setMonth(endOfMonthExhausted.getMonth() + 1, 1) 329: endOfMonthExhausted.setHours(0, 0, 0, 0) 330: mockHeaders['anthropic-ratelimit-unified-overage-reset'] = String( 331: Math.floor(endOfMonthExhausted.getTime() / 1000), 332: ) 333: break 334: } 335: case 'out-of-credits': { 336: if (exceededLimits.length === 0) { 337: exceededLimits = [{ type: 'five_hour', resetsAt: fiveHoursFromNow }] 338: } 339: updateRepresentativeClaim() 340: mockHeaders['anthropic-ratelimit-unified-status'] = 'rejected' 341: mockHeaders['anthropic-ratelimit-unified-overage-status'] = 'rejected' 342: mockHeaders['anthropic-ratelimit-unified-overage-disabled-reason'] = 343: 'out_of_credits' 344: const endOfMonth = new Date() 345: endOfMonth.setMonth(endOfMonth.getMonth() + 1, 1) 346: endOfMonth.setHours(0, 0, 0, 0) 347: mockHeaders['anthropic-ratelimit-unified-overage-reset'] = String( 348: Math.floor(endOfMonth.getTime() / 1000), 349: ) 350: break 351: } 352: case 'org-zero-credit-limit': { 353: if (exceededLimits.length === 0) { 354: exceededLimits = [{ type: 'five_hour', resetsAt: fiveHoursFromNow }] 355: } 356: updateRepresentativeClaim() 357: mockHeaders['anthropic-ratelimit-unified-status'] = 'rejected' 358: mockHeaders['anthropic-ratelimit-unified-overage-status'] = 'rejected' 359: mockHeaders['anthropic-ratelimit-unified-overage-disabled-reason'] = 360: 'org_service_zero_credit_limit' 361: const endOfMonthZero = new Date() 362: endOfMonthZero.setMonth(endOfMonthZero.getMonth() + 1, 1) 363: endOfMonthZero.setHours(0, 0, 0, 0) 364: mockHeaders['anthropic-ratelimit-unified-overage-reset'] = String( 365: Math.floor(endOfMonthZero.getTime() / 1000), 366: ) 367: break 368: } 369: case 'org-spend-cap-hit': { 370: if (exceededLimits.length === 0) { 371: exceededLimits = [{ type: 'five_hour', resetsAt: fiveHoursFromNow }] 372: } 373: updateRepresentativeClaim() 374: mockHeaders['anthropic-ratelimit-unified-status'] = 'rejected' 375: mockHeaders['anthropic-ratelimit-unified-overage-status'] = 'rejected' 376: mockHeaders['anthropic-ratelimit-unified-overage-disabled-reason'] = 377: 'org_level_disabled_until' 378: const endOfMonthHit = new Date() 379: endOfMonthHit.setMonth(endOfMonthHit.getMonth() + 1, 1) 380: endOfMonthHit.setHours(0, 0, 0, 0) 381: mockHeaders['anthropic-ratelimit-unified-overage-reset'] = String( 382: Math.floor(endOfMonthHit.getTime() / 1000), 383: ) 384: break 385: } 386: case 'member-zero-credit-limit': { 387: if (exceededLimits.length === 0) { 388: exceededLimits = [{ type: 'five_hour', resetsAt: fiveHoursFromNow }] 389: } 390: updateRepresentativeClaim() 391: mockHeaders['anthropic-ratelimit-unified-status'] = 'rejected' 392: mockHeaders['anthropic-ratelimit-unified-overage-status'] = 'rejected' 393: mockHeaders['anthropic-ratelimit-unified-overage-disabled-reason'] = 394: 'member_zero_credit_limit' 395: const endOfMonthMember = new Date() 396: endOfMonthMember.setMonth(endOfMonthMember.getMonth() + 1, 1) 397: endOfMonthMember.setHours(0, 0, 0, 0) 398: mockHeaders['anthropic-ratelimit-unified-overage-reset'] = String( 399: Math.floor(endOfMonthMember.getTime() / 1000), 400: ) 401: break 402: } 403: case 'seat-tier-zero-credit-limit': { 404: if (exceededLimits.length === 0) { 405: exceededLimits = [{ type: 'five_hour', resetsAt: fiveHoursFromNow }] 406: } 407: updateRepresentativeClaim() 408: mockHeaders['anthropic-ratelimit-unified-status'] = 'rejected' 409: mockHeaders['anthropic-ratelimit-unified-overage-status'] = 'rejected' 410: mockHeaders['anthropic-ratelimit-unified-overage-disabled-reason'] = 411: 'seat_tier_zero_credit_limit' 412: const endOfMonthSeatTier = new Date() 413: endOfMonthSeatTier.setMonth(endOfMonthSeatTier.getMonth() + 1, 1) 414: endOfMonthSeatTier.setHours(0, 0, 0, 0) 415: mockHeaders['anthropic-ratelimit-unified-overage-reset'] = String( 416: Math.floor(endOfMonthSeatTier.getTime() / 1000), 417: ) 418: break 419: } 420: case 'opus-limit': { 421: exceededLimits = [{ type: 'seven_day_opus', resetsAt: sevenDaysFromNow }] 422: updateRepresentativeClaim() 423: mockHeaders['anthropic-ratelimit-unified-status'] = 'rejected' 424: break 425: } 426: case 'opus-warning': { 427: mockHeaders = { 428: 'anthropic-ratelimit-unified-status': 'allowed_warning', 429: 'anthropic-ratelimit-unified-reset': String(sevenDaysFromNow), 430: 'anthropic-ratelimit-unified-representative-claim': 'seven_day_opus', 431: } 432: break 433: } 434: case 'sonnet-limit': { 435: exceededLimits = [ 436: { type: 'seven_day_sonnet', resetsAt: sevenDaysFromNow }, 437: ] 438: updateRepresentativeClaim() 439: mockHeaders['anthropic-ratelimit-unified-status'] = 'rejected' 440: break 441: } 442: case 'sonnet-warning': { 443: mockHeaders = { 444: 'anthropic-ratelimit-unified-status': 'allowed_warning', 445: 'anthropic-ratelimit-unified-reset': String(sevenDaysFromNow), 446: 'anthropic-ratelimit-unified-representative-claim': 'seven_day_sonnet', 447: } 448: break 449: } 450: case 'fast-mode-limit': { 451: updateRepresentativeClaim() 452: mockHeaders['anthropic-ratelimit-unified-status'] = 'rejected' 453: mockFastModeRateLimitDurationMs = 10 * 60 * 1000 454: break 455: } 456: case 'fast-mode-short-limit': { 457: updateRepresentativeClaim() 458: mockHeaders['anthropic-ratelimit-unified-status'] = 'rejected' 459: mockFastModeRateLimitDurationMs = 10 * 1000 460: break 461: } 462: case 'extra-usage-required': { 463: mockHeaderless429Message = 464: 'Extra usage is required for long context requests.' 465: break 466: } 467: default: 468: break 469: } 470: } 471: export function getMockHeaderless429Message(): string | null { 472: if (process.env.USER_TYPE !== 'ant') { 473: return null 474: } 475: if (process.env.CLAUDE_MOCK_HEADERLESS_429) { 476: return process.env.CLAUDE_MOCK_HEADERLESS_429 477: } 478: if (!mockEnabled) { 479: return null 480: } 481: return mockHeaderless429Message 482: } 483: export function getMockHeaders(): MockHeaders | null { 484: if ( 485: !mockEnabled || 486: process.env.USER_TYPE !== 'ant' || 487: Object.keys(mockHeaders).length === 0 488: ) { 489: return null 490: } 491: return mockHeaders 492: } 493: export function getMockStatus(): string { 494: if ( 495: !mockEnabled || 496: (Object.keys(mockHeaders).length === 0 && !mockSubscriptionType) 497: ) { 498: return 'No mock headers active (using real limits)' 499: } 500: const lines: string[] = [] 501: lines.push('Active mock headers:') 502: const effectiveSubscription = 503: mockSubscriptionType || DEFAULT_MOCK_SUBSCRIPTION 504: if (mockSubscriptionType) { 505: lines.push(` Subscription Type: ${mockSubscriptionType} (explicitly set)`) 506: } else { 507: lines.push(` Subscription Type: ${effectiveSubscription} (default)`) 508: } 509: Object.entries(mockHeaders).forEach(([key, value]) => { 510: if (value !== undefined) { 511: const formattedKey = key 512: .replace('anthropic-ratelimit-unified-', '') 513: .replace(/-/g, ' ') 514: .replace(/\b\w/g, c => c.toUpperCase()) 515: // Format timestamps as human-readable 516: if (key.includes('reset') && value) { 517: const timestamp = Number(value) 518: const date = new Date(timestamp * 1000) 519: lines.push(` ${formattedKey}: ${value} (${date.toLocaleString()})`) 520: } else { 521: lines.push(` ${formattedKey}: ${value}`) 522: } 523: } 524: }) 525: if (exceededLimits.length > 0) { 526: lines.push('\nExceeded limits (contributing to representative claim):') 527: exceededLimits.forEach(limit => { 528: const date = new Date(limit.resetsAt * 1000) 529: lines.push(` ${limit.type}: resets at ${date.toLocaleString()}`) 530: }) 531: } 532: return lines.join('\n') 533: } 534: export function clearMockHeaders(): void { 535: mockHeaders = {} 536: exceededLimits = [] 537: mockSubscriptionType = null 538: mockFastModeRateLimitDurationMs = null 539: mockFastModeRateLimitExpiresAt = null 540: mockHeaderless429Message = null 541: setMockBillingAccessOverride(null) 542: mockEnabled = false 543: } 544: export function applyMockHeaders( 545: headers: globalThis.Headers, 546: ): globalThis.Headers { 547: const mock = getMockHeaders() 548: if (!mock) { 549: return headers 550: } 551: const newHeaders = new globalThis.Headers(headers) 552: Object.entries(mock).forEach(([key, value]) => { 553: if (value !== undefined) { 554: newHeaders.set(key, value) 555: } 556: }) 557: return newHeaders 558: } 559: export function shouldProcessMockLimits(): boolean { 560: if (process.env.USER_TYPE !== 'ant') { 561: return false 562: } 563: return mockEnabled || Boolean(process.env.CLAUDE_MOCK_HEADERLESS_429) 564: } 565: export function getCurrentMockScenario(): MockScenario | null { 566: if (!mockEnabled) { 567: return null 568: } 569: if (!mockHeaders) return null 570: const status = mockHeaders['anthropic-ratelimit-unified-status'] 571: const overage = mockHeaders['anthropic-ratelimit-unified-overage-status'] 572: const claim = mockHeaders['anthropic-ratelimit-unified-representative-claim'] 573: if (claim === 'seven_day_opus') { 574: return status === 'rejected' ? 'opus-limit' : 'opus-warning' 575: } 576: if (claim === 'seven_day_sonnet') { 577: return status === 'rejected' ? 'sonnet-limit' : 'sonnet-warning' 578: } 579: if (overage === 'rejected') return 'overage-exhausted' 580: if (overage === 'allowed_warning') return 'overage-warning' 581: if (overage === 'allowed') return 'overage-active' 582: if (status === 'rejected') { 583: if (claim === 'five_hour') return 'session-limit-reached' 584: if (claim === 'seven_day') return 'weekly-limit-reached' 585: } 586: if (status === 'allowed_warning') { 587: if (claim === 'seven_day') return 'approaching-weekly-limit' 588: } 589: if (status === 'allowed') return 'normal' 590: return null 591: } 592: export function getScenarioDescription(scenario: MockScenario): string { 593: switch (scenario) { 594: case 'normal': 595: return 'Normal usage, no limits' 596: case 'session-limit-reached': 597: return 'Session rate limit exceeded' 598: case 'approaching-weekly-limit': 599: return 'Approaching weekly aggregate limit' 600: case 'weekly-limit-reached': 601: return 'Weekly aggregate limit exceeded' 602: case 'overage-active': 603: return 'Using extra usage (overage active)' 604: case 'overage-warning': 605: return 'Approaching extra usage limit' 606: case 'overage-exhausted': 607: return 'Both subscription and extra usage limits exhausted' 608: case 'out-of-credits': 609: return 'Out of extra usage credits (wallet empty)' 610: case 'org-zero-credit-limit': 611: return 'Org spend cap is zero (no extra usage budget)' 612: case 'org-spend-cap-hit': 613: return 'Org spend cap hit for the month' 614: case 'member-zero-credit-limit': 615: return 'Member limit is zero (admin can allocate more)' 616: case 'seat-tier-zero-credit-limit': 617: return 'Seat tier limit is zero (admin can allocate more)' 618: case 'opus-limit': 619: return 'Opus limit reached' 620: case 'opus-warning': 621: return 'Approaching Opus limit' 622: case 'sonnet-limit': 623: return 'Sonnet limit reached' 624: case 'sonnet-warning': 625: return 'Approaching Sonnet limit' 626: case 'fast-mode-limit': 627: return 'Fast mode rate limit' 628: case 'fast-mode-short-limit': 629: return 'Fast mode rate limit (short)' 630: case 'extra-usage-required': 631: return 'Headerless 429: Extra usage required for 1M context' 632: case 'clear': 633: return 'Clear mock headers (use real limits)' 634: default: 635: return 'Unknown scenario' 636: } 637: } 638: export function setMockSubscriptionType( 639: subscriptionType: SubscriptionType | null, 640: ): void { 641: if (process.env.USER_TYPE !== 'ant') { 642: return 643: } 644: mockEnabled = true 645: mockSubscriptionType = subscriptionType 646: } 647: export function getMockSubscriptionType(): SubscriptionType | null { 648: if (!mockEnabled || process.env.USER_TYPE !== 'ant') { 649: return null 650: } 651: return mockSubscriptionType || DEFAULT_MOCK_SUBSCRIPTION 652: } 653: export function shouldUseMockSubscription(): boolean { 654: return ( 655: mockEnabled && 656: mockSubscriptionType !== null && 657: process.env.USER_TYPE === 'ant' 658: ) 659: } 660: export function setMockBillingAccess(hasAccess: boolean | null): void { 661: if (process.env.USER_TYPE !== 'ant') { 662: return 663: } 664: mockEnabled = true 665: setMockBillingAccessOverride(hasAccess) 666: } 667: export function isMockFastModeRateLimitScenario(): boolean { 668: return mockFastModeRateLimitDurationMs !== null 669: } 670: export function checkMockFastModeRateLimit( 671: isFastModeActive?: boolean, 672: ): MockHeaders | null { 673: if (mockFastModeRateLimitDurationMs === null) { 674: return null 675: } 676: if (!isFastModeActive) { 677: return null 678: } 679: if ( 680: mockFastModeRateLimitExpiresAt !== null && 681: Date.now() >= mockFastModeRateLimitExpiresAt 682: ) { 683: clearMockHeaders() 684: return null 685: } 686: if (mockFastModeRateLimitExpiresAt === null) { 687: mockFastModeRateLimitExpiresAt = 688: Date.now() + mockFastModeRateLimitDurationMs 689: } 690: const remainingMs = mockFastModeRateLimitExpiresAt - Date.now() 691: const headersToSend = { ...mockHeaders } 692: headersToSend['retry-after'] = String( 693: Math.max(1, Math.ceil(remainingMs / 1000)), 694: ) 695: return headersToSend 696: }

File: src/services/notifier.ts

typescript 1: import type { TerminalNotification } from '../ink/useTerminalNotification.js' 2: import { getGlobalConfig } from '../utils/config.js' 3: import { env } from '../utils/env.js' 4: import { execFileNoThrow } from '../utils/execFileNoThrow.js' 5: import { executeNotificationHooks } from '../utils/hooks.js' 6: import { logError } from '../utils/log.js' 7: import { 8: type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 9: logEvent, 10: } from './analytics/index.js' 11: export type NotificationOptions = { 12: message: string 13: title?: string 14: notificationType: string 15: } 16: export async function sendNotification( 17: notif: NotificationOptions, 18: terminal: TerminalNotification, 19: ): Promise<void> { 20: const config = getGlobalConfig() 21: const channel = config.preferredNotifChannel 22: await executeNotificationHooks(notif) 23: const methodUsed = await sendToChannel(channel, notif, terminal) 24: logEvent('tengu_notification_method_used', { 25: configured_channel: 26: channel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 27: method_used: 28: methodUsed as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 29: term: env.terminal as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 30: }) 31: } 32: const DEFAULT_TITLE = 'Claude Code' 33: async function sendToChannel( 34: channel: string, 35: opts: NotificationOptions, 36: terminal: TerminalNotification, 37: ): Promise<string> { 38: const title = opts.title || DEFAULT_TITLE 39: try { 40: switch (channel) { 41: case 'auto': 42: return sendAuto(opts, terminal) 43: case 'iterm2': 44: terminal.notifyITerm2(opts) 45: return 'iterm2' 46: case 'iterm2_with_bell': 47: terminal.notifyITerm2(opts) 48: terminal.notifyBell() 49: return 'iterm2_with_bell' 50: case 'kitty': 51: terminal.notifyKitty({ ...opts, title, id: generateKittyId() }) 52: return 'kitty' 53: case 'ghostty': 54: terminal.notifyGhostty({ ...opts, title }) 55: return 'ghostty' 56: case 'terminal_bell': 57: terminal.notifyBell() 58: return 'terminal_bell' 59: case 'notifications_disabled': 60: return 'disabled' 61: default: 62: return 'none' 63: } 64: } catch { 65: return 'error' 66: } 67: } 68: async function sendAuto( 69: opts: NotificationOptions, 70: terminal: TerminalNotification, 71: ): Promise<string> { 72: const title = opts.title || DEFAULT_TITLE 73: switch (env.terminal) { 74: case 'Apple_Terminal': { 75: const bellDisabled = await isAppleTerminalBellDisabled() 76: if (bellDisabled) { 77: terminal.notifyBell() 78: return 'terminal_bell' 79: } 80: return 'no_method_available' 81: } 82: case 'iTerm.app': 83: terminal.notifyITerm2(opts) 84: return 'iterm2' 85: case 'kitty': 86: terminal.notifyKitty({ ...opts, title, id: generateKittyId() }) 87: return 'kitty' 88: case 'ghostty': 89: terminal.notifyGhostty({ ...opts, title }) 90: return 'ghostty' 91: default: 92: return 'no_method_available' 93: } 94: } 95: function generateKittyId(): number { 96: return Math.floor(Math.random() * 10000) 97: } 98: async function isAppleTerminalBellDisabled(): Promise<boolean> { 99: try { 100: if (env.terminal !== 'Apple_Terminal') { 101: return false 102: } 103: const osascriptResult = await execFileNoThrow('osascript', [ 104: '-e', 105: 'tell application "Terminal" to name of current settings of front window', 106: ]) 107: const currentProfile = osascriptResult.stdout.trim() 108: if (!currentProfile) { 109: return false 110: } 111: const defaultsOutput = await execFileNoThrow('defaults', [ 112: 'export', 113: 'com.apple.Terminal', 114: '-', 115: ]) 116: if (defaultsOutput.code !== 0) { 117: return false 118: } 119: const plist = await import('plist') 120: const parsed: Record<string, unknown> = plist.parse(defaultsOutput.stdout) 121: const windowSettings = parsed?.['Window Settings'] as 122: | Record<string, unknown> 123: | undefined 124: const profileSettings = windowSettings?.[currentProfile] as 125: | Record<string, unknown> 126: | undefined 127: if (!profileSettings) { 128: return false 129: } 130: return profileSettings.Bell === false 131: } catch (error) { 132: logError(error) 133: return false 134: } 135: }

File: src/services/preventSleep.ts

typescript 1: import { type ChildProcess, spawn } from 'child_process' 2: import { registerCleanup } from '../utils/cleanupRegistry.js' 3: import { logForDebugging } from '../utils/debug.js' 4: const CAFFEINATE_TIMEOUT_SECONDS = 300 5: const RESTART_INTERVAL_MS = 4 * 60 * 1000 6: let caffeinateProcess: ChildProcess | null = null 7: let restartInterval: ReturnType<typeof setInterval> | null = null 8: let refCount = 0 9: let cleanupRegistered = false 10: export function startPreventSleep(): void { 11: refCount++ 12: if (refCount === 1) { 13: spawnCaffeinate() 14: startRestartInterval() 15: } 16: } 17: export function stopPreventSleep(): void { 18: if (refCount > 0) { 19: refCount-- 20: } 21: if (refCount === 0) { 22: stopRestartInterval() 23: killCaffeinate() 24: } 25: } 26: export function forceStopPreventSleep(): void { 27: refCount = 0 28: stopRestartInterval() 29: killCaffeinate() 30: } 31: function startRestartInterval(): void { 32: if (process.platform !== 'darwin') { 33: return 34: } 35: if (restartInterval !== null) { 36: return 37: } 38: restartInterval = setInterval(() => { 39: if (refCount > 0) { 40: logForDebugging('Restarting caffeinate to maintain sleep prevention') 41: killCaffeinate() 42: spawnCaffeinate() 43: } 44: }, RESTART_INTERVAL_MS) 45: restartInterval.unref() 46: } 47: function stopRestartInterval(): void { 48: if (restartInterval !== null) { 49: clearInterval(restartInterval) 50: restartInterval = null 51: } 52: } 53: function spawnCaffeinate(): void { 54: if (process.platform !== 'darwin') { 55: return 56: } 57: if (caffeinateProcess !== null) { 58: return 59: } 60: if (!cleanupRegistered) { 61: cleanupRegistered = true 62: registerCleanup(async () => { 63: forceStopPreventSleep() 64: }) 65: } 66: try { 67: caffeinateProcess = spawn( 68: 'caffeinate', 69: ['-i', '-t', String(CAFFEINATE_TIMEOUT_SECONDS)], 70: { 71: stdio: 'ignore', 72: }, 73: ) 74: caffeinateProcess.unref() 75: const thisProc = caffeinateProcess 76: caffeinateProcess.on('error', err => { 77: logForDebugging(`caffeinate spawn error: ${err.message}`) 78: if (caffeinateProcess === thisProc) caffeinateProcess = null 79: }) 80: caffeinateProcess.on('exit', () => { 81: if (caffeinateProcess === thisProc) caffeinateProcess = null 82: }) 83: logForDebugging('Started caffeinate to prevent sleep') 84: } catch { 85: caffeinateProcess = null 86: } 87: } 88: function killCaffeinate(): void { 89: if (caffeinateProcess !== null) { 90: const proc = caffeinateProcess 91: caffeinateProcess = null 92: try { 93: proc.kill('SIGKILL') 94: logForDebugging('Stopped caffeinate, allowing sleep') 95: } catch { 96: } 97: } 98: }

File: src/services/rateLimitMessages.ts

typescript 1: import { 2: getOauthAccountInfo, 3: getSubscriptionType, 4: isOverageProvisioningAllowed, 5: } from '../utils/auth.js' 6: import { hasClaudeAiBillingAccess } from '../utils/billing.js' 7: import { formatResetTime } from '../utils/format.js' 8: import type { ClaudeAILimits } from './claudeAiLimits.js' 9: const FEEDBACK_CHANNEL_ANT = '#briarpatch-cc' 10: export const RATE_LIMIT_ERROR_PREFIXES = [ 11: "You've hit your", 12: "You've used", 13: "You're now using extra usage", 14: "You're close to", 15: "You're out of extra usage", 16: ] as const 17: export function isRateLimitErrorMessage(text: string): boolean { 18: return RATE_LIMIT_ERROR_PREFIXES.some(prefix => text.startsWith(prefix)) 19: } 20: export type RateLimitMessage = { 21: message: string 22: severity: 'error' | 'warning' 23: } 24: export function getRateLimitMessage( 25: limits: ClaudeAILimits, 26: model: string, 27: ): RateLimitMessage | null { 28: if (limits.isUsingOverage) { 29: if (limits.overageStatus === 'allowed_warning') { 30: return { 31: message: "You're close to your extra usage spending limit", 32: severity: 'warning', 33: } 34: } 35: return null 36: } 37: if (limits.status === 'rejected') { 38: return { message: getLimitReachedText(limits, model), severity: 'error' } 39: } 40: if (limits.status === 'allowed_warning') { 41: const WARNING_THRESHOLD = 0.7 42: if ( 43: limits.utilization !== undefined && 44: limits.utilization < WARNING_THRESHOLD 45: ) { 46: return null 47: } 48: const subscriptionType = getSubscriptionType() 49: const isTeamOrEnterprise = 50: subscriptionType === 'team' || subscriptionType === 'enterprise' 51: const hasExtraUsageEnabled = 52: getOauthAccountInfo()?.hasExtraUsageEnabled === true 53: if ( 54: isTeamOrEnterprise && 55: hasExtraUsageEnabled && 56: !hasClaudeAiBillingAccess() 57: ) { 58: return null 59: } 60: const text = getEarlyWarningText(limits) 61: if (text) { 62: return { message: text, severity: 'warning' } 63: } 64: } 65: return null 66: } 67: export function getRateLimitErrorMessage( 68: limits: ClaudeAILimits, 69: model: string, 70: ): string | null { 71: const message = getRateLimitMessage(limits, model) 72: if (message && message.severity === 'error') { 73: return message.message 74: } 75: return null 76: } 77: export function getRateLimitWarning( 78: limits: ClaudeAILimits, 79: model: string, 80: ): string | null { 81: const message = getRateLimitMessage(limits, model) 82: if (message && message.severity === 'warning') { 83: return message.message 84: } 85: return null 86: } 87: function getLimitReachedText(limits: ClaudeAILimits, model: string): string { 88: const resetsAt = limits.resetsAt 89: const resetTime = resetsAt ? formatResetTime(resetsAt, true) : undefined 90: const overageResetTime = limits.overageResetsAt 91: ? formatResetTime(limits.overageResetsAt, true) 92: : undefined 93: const resetMessage = resetTime ? ` · resets ${resetTime}` : '' 94: // if BOTH subscription (checked before this method) and overage are exhausted 95: if (limits.overageStatus === 'rejected') { 96: let overageResetMessage = '' 97: if (resetsAt && limits.overageResetsAt) { 98: // Both timestamps present - use the earlier one 99: if (resetsAt < limits.overageResetsAt) { 100: overageResetMessage = ` · resets ${resetTime}` 101: } else { 102: overageResetMessage = ` · resets ${overageResetTime}` 103: } 104: } else if (resetTime) { 105: overageResetMessage = ` · resets ${resetTime}` 106: } else if (overageResetTime) { 107: overageResetMessage = ` · resets ${overageResetTime}` 108: } 109: if (limits.overageDisabledReason === 'out_of_credits') { 110: return `You're out of extra usage${overageResetMessage}` 111: } 112: return formatLimitReachedText('limit', overageResetMessage, model) 113: } 114: if (limits.rateLimitType === 'seven_day_sonnet') { 115: const subscriptionType = getSubscriptionType() 116: const isProOrEnterprise = 117: subscriptionType === 'pro' || subscriptionType === 'enterprise' 118: const limit = isProOrEnterprise ? 'weekly limit' : 'Sonnet limit' 119: return formatLimitReachedText(limit, resetMessage, model) 120: } 121: if (limits.rateLimitType === 'seven_day_opus') { 122: return formatLimitReachedText('Opus limit', resetMessage, model) 123: } 124: if (limits.rateLimitType === 'seven_day') { 125: return formatLimitReachedText('weekly limit', resetMessage, model) 126: } 127: if (limits.rateLimitType === 'five_hour') { 128: return formatLimitReachedText('session limit', resetMessage, model) 129: } 130: return formatLimitReachedText('usage limit', resetMessage, model) 131: } 132: function getEarlyWarningText(limits: ClaudeAILimits): string | null { 133: let limitName: string | null = null 134: switch (limits.rateLimitType) { 135: case 'seven_day': 136: limitName = 'weekly limit' 137: break 138: case 'five_hour': 139: limitName = 'session limit' 140: break 141: case 'seven_day_opus': 142: limitName = 'Opus limit' 143: break 144: case 'seven_day_sonnet': 145: limitName = 'Sonnet limit' 146: break 147: case 'overage': 148: limitName = 'extra usage' 149: break 150: case undefined: 151: return null 152: } 153: const used = limits.utilization 154: ? Math.floor(limits.utilization * 100) 155: : undefined 156: const resetTime = limits.resetsAt 157: ? formatResetTime(limits.resetsAt, true) 158: : undefined 159: const upsell = getWarningUpsellText(limits.rateLimitType) 160: if (used && resetTime) { 161: const base = `You've used ${used}% of your ${limitName} · resets ${resetTime}` 162: return upsell ? `${base} · ${upsell}` : base 163: } 164: if (used) { 165: const base = `You've used ${used}% of your ${limitName}` 166: return upsell ? `${base} · ${upsell}` : base 167: } 168: if (limits.rateLimitType === 'overage') { 169: limitName += ' limit' 170: } 171: if (resetTime) { 172: const base = `Approaching ${limitName} · resets ${resetTime}` 173: return upsell ? `${base} · ${upsell}` : base 174: } 175: const base = `Approaching ${limitName}` 176: return upsell ? `${base} · ${upsell}` : base 177: } 178: function getWarningUpsellText( 179: rateLimitType: ClaudeAILimits['rateLimitType'], 180: ): string | null { 181: const subscriptionType = getSubscriptionType() 182: const hasExtraUsageEnabled = 183: getOauthAccountInfo()?.hasExtraUsageEnabled === true 184: if (rateLimitType === 'five_hour') { 185: if (subscriptionType === 'team' || subscriptionType === 'enterprise') { 186: if (!hasExtraUsageEnabled && isOverageProvisioningAllowed()) { 187: return '/extra-usage to request more' 188: } 189: return null 190: } 191: if (subscriptionType === 'pro' || subscriptionType === 'max') { 192: return '/upgrade to keep using Claude Code' 193: } 194: } 195: if (rateLimitType === 'overage') { 196: if (subscriptionType === 'team' || subscriptionType === 'enterprise') { 197: if (!hasExtraUsageEnabled && isOverageProvisioningAllowed()) { 198: return '/extra-usage to request more' 199: } 200: } 201: } 202: return null 203: } 204: export function getUsingOverageText(limits: ClaudeAILimits): string { 205: const resetTime = limits.resetsAt 206: ? formatResetTime(limits.resetsAt, true) 207: : '' 208: let limitName = '' 209: if (limits.rateLimitType === 'five_hour') { 210: limitName = 'session limit' 211: } else if (limits.rateLimitType === 'seven_day') { 212: limitName = 'weekly limit' 213: } else if (limits.rateLimitType === 'seven_day_opus') { 214: limitName = 'Opus limit' 215: } else if (limits.rateLimitType === 'seven_day_sonnet') { 216: const subscriptionType = getSubscriptionType() 217: const isProOrEnterprise = 218: subscriptionType === 'pro' || subscriptionType === 'enterprise' 219: limitName = isProOrEnterprise ? 'weekly limit' : 'Sonnet limit' 220: } 221: if (!limitName) { 222: return 'Now using extra usage' 223: } 224: const resetMessage = resetTime 225: ? ` · Your ${limitName} resets ${resetTime}` 226: : '' 227: return `You're now using extra usage${resetMessage}` 228: } 229: function formatLimitReachedText( 230: limit: string, 231: resetMessage: string, 232: _model: string, 233: ): string { 234: // Enhanced messaging for Ant users 235: if (process.env.USER_TYPE === 'ant') { 236: return `You've hit your ${limit}${resetMessage}. If you have feedback about this limit, post in ${FEEDBACK_CHANNEL_ANT}. You can reset your limits with /reset-limits` 237: } 238: return `You've hit your ${limit}${resetMessage}` 239: }

File: src/services/rateLimitMocking.ts

typescript 1: import { APIError } from '@anthropic-ai/sdk' 2: import { 3: applyMockHeaders, 4: checkMockFastModeRateLimit, 5: getMockHeaderless429Message, 6: getMockHeaders, 7: isMockFastModeRateLimitScenario, 8: shouldProcessMockLimits, 9: } from './mockRateLimits.js' 10: export function processRateLimitHeaders( 11: headers: globalThis.Headers, 12: ): globalThis.Headers { 13: if (shouldProcessMockLimits()) { 14: return applyMockHeaders(headers) 15: } 16: return headers 17: } 18: export function shouldProcessRateLimits(isSubscriber: boolean): boolean { 19: return isSubscriber || shouldProcessMockLimits() 20: } 21: export function checkMockRateLimitError( 22: currentModel: string, 23: isFastModeActive?: boolean, 24: ): APIError | null { 25: if (!shouldProcessMockLimits()) { 26: return null 27: } 28: const headerlessMessage = getMockHeaderless429Message() 29: if (headerlessMessage) { 30: return new APIError( 31: 429, 32: { error: { type: 'rate_limit_error', message: headerlessMessage } }, 33: headerlessMessage, 34: new globalThis.Headers(), 35: ) 36: } 37: const mockHeaders = getMockHeaders() 38: if (!mockHeaders) { 39: return null 40: } 41: const status = mockHeaders['anthropic-ratelimit-unified-status'] 42: const overageStatus = 43: mockHeaders['anthropic-ratelimit-unified-overage-status'] 44: const rateLimitType = 45: mockHeaders['anthropic-ratelimit-unified-representative-claim'] 46: const isOpusLimit = rateLimitType === 'seven_day_opus' 47: const isUsingOpus = currentModel.includes('opus') 48: if (isOpusLimit && !isUsingOpus) { 49: return null 50: } 51: if (isMockFastModeRateLimitScenario()) { 52: const fastModeHeaders = checkMockFastModeRateLimit(isFastModeActive) 53: if (fastModeHeaders === null) { 54: return null 55: } 56: const error = new APIError( 57: 429, 58: { error: { type: 'rate_limit_error', message: 'Rate limit exceeded' } }, 59: 'Rate limit exceeded', 60: new globalThis.Headers( 61: Object.entries(fastModeHeaders).filter(([_, v]) => v !== undefined) as [ 62: string, 63: string, 64: ][], 65: ), 66: ) 67: return error 68: } 69: const shouldThrow429 = 70: status === 'rejected' && (!overageStatus || overageStatus === 'rejected') 71: if (shouldThrow429) { 72: const error = new APIError( 73: 429, 74: { error: { type: 'rate_limit_error', message: 'Rate limit exceeded' } }, 75: 'Rate limit exceeded', 76: new globalThis.Headers( 77: Object.entries(mockHeaders).filter(([_, v]) => v !== undefined) as [ 78: string, 79: string, 80: ][], 81: ), 82: ) 83: return error 84: } 85: return null 86: } 87: export function isMockRateLimitError(error: APIError): boolean { 88: return shouldProcessMockLimits() && error.status === 429 89: } 90: export { shouldProcessMockLimits }

File: src/services/tokenEstimation.ts

typescript 1: import type { Anthropic } from '@anthropic-ai/sdk' 2: import type { BetaMessageParam as MessageParam } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs' 3: import type { CountTokensCommandInput } from '@aws-sdk/client-bedrock-runtime' 4: import { getAPIProvider } from 'src/utils/model/providers.js' 5: import { VERTEX_COUNT_TOKENS_ALLOWED_BETAS } from '../constants/betas.js' 6: import type { Attachment } from '../utils/attachments.js' 7: import { getModelBetas } from '../utils/betas.js' 8: import { getVertexRegionForModel, isEnvTruthy } from '../utils/envUtils.js' 9: import { logError } from '../utils/log.js' 10: import { normalizeAttachmentForAPI } from '../utils/messages.js' 11: import { 12: createBedrockRuntimeClient, 13: getInferenceProfileBackingModel, 14: isFoundationModel, 15: } from '../utils/model/bedrock.js' 16: import { 17: getDefaultSonnetModel, 18: getMainLoopModel, 19: getSmallFastModel, 20: normalizeModelStringForAPI, 21: } from '../utils/model/model.js' 22: import { jsonStringify } from '../utils/slowOperations.js' 23: import { isToolReferenceBlock } from '../utils/toolSearch.js' 24: import { getAPIMetadata, getExtraBodyParams } from './api/claude.js' 25: import { getAnthropicClient } from './api/client.js' 26: import { withTokenCountVCR } from './vcr.js' 27: const TOKEN_COUNT_THINKING_BUDGET = 1024 28: const TOKEN_COUNT_MAX_TOKENS = 2048 29: function hasThinkingBlocks( 30: messages: Anthropic.Beta.Messages.BetaMessageParam[], 31: ): boolean { 32: for (const message of messages) { 33: if (message.role === 'assistant' && Array.isArray(message.content)) { 34: for (const block of message.content) { 35: if ( 36: typeof block === 'object' && 37: block !== null && 38: 'type' in block && 39: (block.type === 'thinking' || block.type === 'redacted_thinking') 40: ) { 41: return true 42: } 43: } 44: } 45: } 46: return false 47: } 48: function stripToolSearchFieldsFromMessages( 49: messages: Anthropic.Beta.Messages.BetaMessageParam[], 50: ): Anthropic.Beta.Messages.BetaMessageParam[] { 51: return messages.map(message => { 52: if (!Array.isArray(message.content)) { 53: return message 54: } 55: const normalizedContent = message.content.map(block => { 56: if (block.type === 'tool_use') { 57: const toolUse = 58: block as Anthropic.Beta.Messages.BetaToolUseBlockParam & { 59: caller?: unknown 60: } 61: return { 62: type: 'tool_use' as const, 63: id: toolUse.id, 64: name: toolUse.name, 65: input: toolUse.input, 66: } 67: } 68: if (block.type === 'tool_result') { 69: const toolResult = 70: block as Anthropic.Beta.Messages.BetaToolResultBlockParam 71: if (Array.isArray(toolResult.content)) { 72: const filteredContent = (toolResult.content as unknown[]).filter( 73: c => !isToolReferenceBlock(c), 74: ) as typeof toolResult.content 75: if (filteredContent.length === 0) { 76: return { 77: ...toolResult, 78: content: [{ type: 'text' as const, text: '[tool references]' }], 79: } 80: } 81: if (filteredContent.length !== toolResult.content.length) { 82: return { 83: ...toolResult, 84: content: filteredContent, 85: } 86: } 87: } 88: } 89: return block 90: }) 91: return { 92: ...message, 93: content: normalizedContent, 94: } 95: }) 96: } 97: export async function countTokensWithAPI( 98: content: string, 99: ): Promise<number | null> { 100: if (!content) { 101: return 0 102: } 103: const message: Anthropic.Beta.Messages.BetaMessageParam = { 104: role: 'user', 105: content: content, 106: } 107: return countMessagesTokensWithAPI([message], []) 108: } 109: export async function countMessagesTokensWithAPI( 110: messages: Anthropic.Beta.Messages.BetaMessageParam[], 111: tools: Anthropic.Beta.Messages.BetaToolUnion[], 112: ): Promise<number | null> { 113: return withTokenCountVCR(messages, tools, async () => { 114: try { 115: const model = getMainLoopModel() 116: const betas = getModelBetas(model) 117: const containsThinking = hasThinkingBlocks(messages) 118: if (getAPIProvider() === 'bedrock') { 119: return countTokensWithBedrock({ 120: model: normalizeModelStringForAPI(model), 121: messages, 122: tools, 123: betas, 124: containsThinking, 125: }) 126: } 127: const anthropic = await getAnthropicClient({ 128: maxRetries: 1, 129: model, 130: source: 'count_tokens', 131: }) 132: const filteredBetas = 133: getAPIProvider() === 'vertex' 134: ? betas.filter(b => VERTEX_COUNT_TOKENS_ALLOWED_BETAS.has(b)) 135: : betas 136: const response = await anthropic.beta.messages.countTokens({ 137: model: normalizeModelStringForAPI(model), 138: messages: 139: messages.length > 0 ? messages : [{ role: 'user', content: 'foo' }], 140: tools, 141: ...(filteredBetas.length > 0 && { betas: filteredBetas }), 142: ...(containsThinking && { 143: thinking: { 144: type: 'enabled', 145: budget_tokens: TOKEN_COUNT_THINKING_BUDGET, 146: }, 147: }), 148: }) 149: if (typeof response.input_tokens !== 'number') { 150: return null 151: } 152: return response.input_tokens 153: } catch (error) { 154: logError(error) 155: return null 156: } 157: }) 158: } 159: export function roughTokenCountEstimation( 160: content: string, 161: bytesPerToken: number = 4, 162: ): number { 163: return Math.round(content.length / bytesPerToken) 164: } 165: export function bytesPerTokenForFileType(fileExtension: string): number { 166: switch (fileExtension) { 167: case 'json': 168: case 'jsonl': 169: case 'jsonc': 170: return 2 171: default: 172: return 4 173: } 174: } 175: export function roughTokenCountEstimationForFileType( 176: content: string, 177: fileExtension: string, 178: ): number { 179: return roughTokenCountEstimation( 180: content, 181: bytesPerTokenForFileType(fileExtension), 182: ) 183: } 184: export async function countTokensViaHaikuFallback( 185: messages: Anthropic.Beta.Messages.BetaMessageParam[], 186: tools: Anthropic.Beta.Messages.BetaToolUnion[], 187: ): Promise<number | null> { 188: const containsThinking = hasThinkingBlocks(messages) 189: const isVertexGlobalEndpoint = 190: isEnvTruthy(process.env.CLAUDE_CODE_USE_VERTEX) && 191: getVertexRegionForModel(getSmallFastModel()) === 'global' 192: const isBedrockWithThinking = 193: isEnvTruthy(process.env.CLAUDE_CODE_USE_BEDROCK) && containsThinking 194: const isVertexWithThinking = 195: isEnvTruthy(process.env.CLAUDE_CODE_USE_VERTEX) && containsThinking 196: const model = 197: isVertexGlobalEndpoint || isBedrockWithThinking || isVertexWithThinking 198: ? getDefaultSonnetModel() 199: : getSmallFastModel() 200: const anthropic = await getAnthropicClient({ 201: maxRetries: 1, 202: model, 203: source: 'count_tokens', 204: }) 205: const normalizedMessages = stripToolSearchFieldsFromMessages(messages) 206: const messagesToSend: MessageParam[] = 207: normalizedMessages.length > 0 208: ? (normalizedMessages as MessageParam[]) 209: : [{ role: 'user', content: 'count' }] 210: const betas = getModelBetas(model) 211: const filteredBetas = 212: getAPIProvider() === 'vertex' 213: ? betas.filter(b => VERTEX_COUNT_TOKENS_ALLOWED_BETAS.has(b)) 214: : betas 215: const response = await anthropic.beta.messages.create({ 216: model: normalizeModelStringForAPI(model), 217: max_tokens: containsThinking ? TOKEN_COUNT_MAX_TOKENS : 1, 218: messages: messagesToSend, 219: tools: tools.length > 0 ? tools : undefined, 220: ...(filteredBetas.length > 0 && { betas: filteredBetas }), 221: metadata: getAPIMetadata(), 222: ...getExtraBodyParams(), 223: ...(containsThinking && { 224: thinking: { 225: type: 'enabled', 226: budget_tokens: TOKEN_COUNT_THINKING_BUDGET, 227: }, 228: }), 229: }) 230: const usage = response.usage 231: const inputTokens = usage.input_tokens 232: const cacheCreationTokens = usage.cache_creation_input_tokens || 0 233: const cacheReadTokens = usage.cache_read_input_tokens || 0 234: return inputTokens + cacheCreationTokens + cacheReadTokens 235: } 236: export function roughTokenCountEstimationForMessages( 237: messages: readonly { 238: type: string 239: message?: { content?: unknown } 240: attachment?: Attachment 241: }[], 242: ): number { 243: let totalTokens = 0 244: for (const message of messages) { 245: totalTokens += roughTokenCountEstimationForMessage(message) 246: } 247: return totalTokens 248: } 249: export function roughTokenCountEstimationForMessage(message: { 250: type: string 251: message?: { content?: unknown } 252: attachment?: Attachment 253: }): number { 254: if ( 255: (message.type === 'assistant' || message.type === 'user') && 256: message.message?.content 257: ) { 258: return roughTokenCountEstimationForContent( 259: message.message?.content as 260: | string 261: | Array<Anthropic.ContentBlock> 262: | Array<Anthropic.ContentBlockParam> 263: | undefined, 264: ) 265: } 266: if (message.type === 'attachment' && message.attachment) { 267: const userMessages = normalizeAttachmentForAPI(message.attachment) 268: let total = 0 269: for (const userMsg of userMessages) { 270: total += roughTokenCountEstimationForContent(userMsg.message.content) 271: } 272: return total 273: } 274: return 0 275: } 276: function roughTokenCountEstimationForContent( 277: content: 278: | string 279: | Array<Anthropic.ContentBlock> 280: | Array<Anthropic.ContentBlockParam> 281: | undefined, 282: ): number { 283: if (!content) { 284: return 0 285: } 286: if (typeof content === 'string') { 287: return roughTokenCountEstimation(content) 288: } 289: let totalTokens = 0 290: for (const block of content) { 291: totalTokens += roughTokenCountEstimationForBlock(block) 292: } 293: return totalTokens 294: } 295: function roughTokenCountEstimationForBlock( 296: block: string | Anthropic.ContentBlock | Anthropic.ContentBlockParam, 297: ): number { 298: if (typeof block === 'string') { 299: return roughTokenCountEstimation(block) 300: } 301: if (block.type === 'text') { 302: return roughTokenCountEstimation(block.text) 303: } 304: if (block.type === 'image' || block.type === 'document') { 305: return 2000 306: } 307: if (block.type === 'tool_result') { 308: return roughTokenCountEstimationForContent(block.content) 309: } 310: if (block.type === 'tool_use') { 311: return roughTokenCountEstimation( 312: block.name + jsonStringify(block.input ?? {}), 313: ) 314: } 315: if (block.type === 'thinking') { 316: return roughTokenCountEstimation(block.thinking) 317: } 318: if (block.type === 'redacted_thinking') { 319: return roughTokenCountEstimation(block.data) 320: } 321: return roughTokenCountEstimation(jsonStringify(block)) 322: } 323: async function countTokensWithBedrock({ 324: model, 325: messages, 326: tools, 327: betas, 328: containsThinking, 329: }: { 330: model: string 331: messages: Anthropic.Beta.Messages.BetaMessageParam[] 332: tools: Anthropic.Beta.Messages.BetaToolUnion[] 333: betas: string[] 334: containsThinking: boolean 335: }): Promise<number | null> { 336: try { 337: const client = await createBedrockRuntimeClient() 338: const modelId = isFoundationModel(model) 339: ? model 340: : await getInferenceProfileBackingModel(model) 341: if (!modelId) { 342: return null 343: } 344: const requestBody = { 345: anthropic_version: 'bedrock-2023-05-31', 346: messages: 347: messages.length > 0 ? messages : [{ role: 'user', content: 'foo' }], 348: max_tokens: containsThinking ? TOKEN_COUNT_MAX_TOKENS : 1, 349: ...(tools.length > 0 && { tools }), 350: ...(betas.length > 0 && { anthropic_beta: betas }), 351: ...(containsThinking && { 352: thinking: { 353: type: 'enabled', 354: budget_tokens: TOKEN_COUNT_THINKING_BUDGET, 355: }, 356: }), 357: } 358: const { CountTokensCommand } = await import( 359: '@aws-sdk/client-bedrock-runtime' 360: ) 361: const input: CountTokensCommandInput = { 362: modelId, 363: input: { 364: invokeModel: { 365: body: new TextEncoder().encode(jsonStringify(requestBody)), 366: }, 367: }, 368: } 369: const response = await client.send(new CountTokensCommand(input)) 370: const tokenCount = response.inputTokens ?? null 371: return tokenCount 372: } catch (error) { 373: logError(error) 374: return null 375: } 376: }

File: src/services/vcr.ts

typescript 1: import type { BetaContentBlock } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs' 2: import { createHash, randomUUID, type UUID } from 'crypto' 3: import { mkdir, readFile, writeFile } from 'fs/promises' 4: import isPlainObject from 'lodash-es/isPlainObject.js' 5: import mapValues from 'lodash-es/mapValues.js' 6: import { dirname, join } from 'path' 7: import { addToTotalSessionCost } from 'src/cost-tracker.js' 8: import { calculateUSDCost } from 'src/utils/modelCost.js' 9: import type { 10: AssistantMessage, 11: Message, 12: StreamEvent, 13: SystemAPIErrorMessage, 14: UserMessage, 15: } from '../types/message.js' 16: import { getCwd } from '../utils/cwd.js' 17: import { env } from '../utils/env.js' 18: import { getClaudeConfigHomeDir, isEnvTruthy } from '../utils/envUtils.js' 19: import { getErrnoCode } from '../utils/errors.js' 20: import { normalizeMessagesForAPI } from '../utils/messages.js' 21: import { jsonParse, jsonStringify } from '../utils/slowOperations.js' 22: function shouldUseVCR(): boolean { 23: if (process.env.NODE_ENV === 'test') { 24: return true 25: } 26: if (process.env.USER_TYPE === 'ant' && isEnvTruthy(process.env.FORCE_VCR)) { 27: return true 28: } 29: return false 30: } 31: async function withFixture<T>( 32: input: unknown, 33: fixtureName: string, 34: f: () => Promise<T>, 35: ): Promise<T> { 36: if (!shouldUseVCR()) { 37: return await f() 38: } 39: const hash = createHash('sha1') 40: .update(jsonStringify(input)) 41: .digest('hex') 42: .slice(0, 12) 43: const filename = join( 44: process.env.CLAUDE_CODE_TEST_FIXTURES_ROOT ?? getCwd(), 45: `fixtures/${fixtureName}-${hash}.json`, 46: ) 47: try { 48: const cached = jsonParse( 49: await readFile(filename, { encoding: 'utf8' }), 50: ) as T 51: return cached 52: } catch (e: unknown) { 53: const code = getErrnoCode(e) 54: if (code !== 'ENOENT') { 55: throw e 56: } 57: } 58: if ((env.isCI || process.env.CI) && !isEnvTruthy(process.env.VCR_RECORD)) { 59: throw new Error( 60: `Fixture missing: ${filename}. Re-run tests with VCR_RECORD=1, then commit the result.`, 61: ) 62: } 63: const result = await f() 64: await mkdir(dirname(filename), { recursive: true }) 65: await writeFile(filename, jsonStringify(result, null, 2), { 66: encoding: 'utf8', 67: }) 68: return result 69: } 70: export async function withVCR( 71: messages: Message[], 72: f: () => Promise<(AssistantMessage | StreamEvent | SystemAPIErrorMessage)[]>, 73: ): Promise<(AssistantMessage | StreamEvent | SystemAPIErrorMessage)[]> { 74: if (!shouldUseVCR()) { 75: return await f() 76: } 77: const messagesForAPI = normalizeMessagesForAPI( 78: messages.filter(_ => { 79: if (_.type !== 'user') { 80: return true 81: } 82: if (_.isMeta) { 83: return false 84: } 85: return true 86: }), 87: ) 88: const dehydratedInput = mapMessages( 89: messagesForAPI.map(_ => _.message.content), 90: dehydrateValue, 91: ) 92: const filename = join( 93: process.env.CLAUDE_CODE_TEST_FIXTURES_ROOT ?? getCwd(), 94: `fixtures/${dehydratedInput.map(_ => createHash('sha1').update(jsonStringify(_)).digest('hex').slice(0, 6)).join('-')}.json`, 95: ) 96: try { 97: const cached = jsonParse( 98: await readFile(filename, { encoding: 'utf8' }), 99: ) as { output: (AssistantMessage | StreamEvent)[] } 100: cached.output.forEach(addCachedCostToTotalSessionCost) 101: return cached.output.map((message, index) => 102: mapMessage(message, hydrateValue, index, randomUUID()), 103: ) 104: } catch (e: unknown) { 105: const code = getErrnoCode(e) 106: if (code !== 'ENOENT') { 107: throw e 108: } 109: } 110: if (env.isCI && !isEnvTruthy(process.env.VCR_RECORD)) { 111: throw new Error( 112: `Anthropic API fixture missing: ${filename}. Re-run tests with VCR_RECORD=1, then commit the result. Input messages:\n${jsonStringify(dehydratedInput, null, 2)}`, 113: ) 114: } 115: const results = await f() 116: if (env.isCI && !isEnvTruthy(process.env.VCR_RECORD)) { 117: return results 118: } 119: await mkdir(dirname(filename), { recursive: true }) 120: await writeFile( 121: filename, 122: jsonStringify( 123: { 124: input: dehydratedInput, 125: output: results.map((message, index) => 126: mapMessage(message, dehydrateValue, index), 127: ), 128: }, 129: null, 130: 2, 131: ), 132: { encoding: 'utf8' }, 133: ) 134: return results 135: } 136: function addCachedCostToTotalSessionCost( 137: message: AssistantMessage | StreamEvent, 138: ): void { 139: if (message.type === 'stream_event') { 140: return 141: } 142: const model = message.message.model 143: const usage = message.message.usage 144: const costUSD = calculateUSDCost(model, usage) 145: addToTotalSessionCost(costUSD, usage, model) 146: } 147: function mapMessages( 148: messages: (UserMessage | AssistantMessage)['message']['content'][], 149: f: (s: unknown) => unknown, 150: ): (UserMessage | AssistantMessage)['message']['content'][] { 151: return messages.map(_ => { 152: if (typeof _ === 'string') { 153: return f(_) 154: } 155: return _.map(_ => { 156: switch (_.type) { 157: case 'tool_result': 158: if (typeof _.content === 'string') { 159: return { ..._, content: f(_.content) } 160: } 161: if (Array.isArray(_.content)) { 162: return { 163: ..._, 164: content: _.content.map(_ => { 165: switch (_.type) { 166: case 'text': 167: return { ..._, text: f(_.text) } 168: case 'image': 169: return _ 170: default: 171: return undefined 172: } 173: }), 174: } 175: } 176: return _ 177: case 'text': 178: return { ..._, text: f(_.text) } 179: case 'tool_use': 180: return { 181: ..._, 182: input: mapValuesDeep(_.input as Record<string, unknown>, f), 183: } 184: case 'image': 185: return _ 186: default: 187: return undefined 188: } 189: }) 190: }) as (UserMessage | AssistantMessage)['message']['content'][] 191: } 192: function mapValuesDeep( 193: obj: { 194: [x: string]: unknown 195: }, 196: f: (val: unknown, key: string, obj: Record<string, unknown>) => unknown, 197: ): Record<string, unknown> { 198: return mapValues(obj, (val, key) => { 199: if (Array.isArray(val)) { 200: return val.map(_ => mapValuesDeep(_, f)) 201: } 202: if (isPlainObject(val)) { 203: return mapValuesDeep(val as Record<string, unknown>, f) 204: } 205: return f(val, key, obj) 206: }) 207: } 208: function mapAssistantMessage( 209: message: AssistantMessage, 210: f: (s: unknown) => unknown, 211: index: number, 212: uuid?: UUID, 213: ): AssistantMessage { 214: return { 215: uuid: uuid ?? (`UUID-${index}` as unknown as UUID), 216: requestId: 'REQUEST_ID', 217: timestamp: message.timestamp, 218: message: { 219: ...message.message, 220: content: message.message.content 221: .map(_ => { 222: switch (_.type) { 223: case 'text': 224: return { 225: ..._, 226: text: f(_.text) as string, 227: citations: _.citations || [], 228: } 229: case 'tool_use': 230: return { 231: ..._, 232: input: mapValuesDeep(_.input as Record<string, unknown>, f), 233: } 234: default: 235: return _ 236: } 237: }) 238: .filter(Boolean) as BetaContentBlock[], 239: }, 240: type: 'assistant', 241: } 242: } 243: function mapMessage( 244: message: AssistantMessage | SystemAPIErrorMessage | StreamEvent, 245: f: (s: unknown) => unknown, 246: index: number, 247: uuid?: UUID, 248: ): AssistantMessage | SystemAPIErrorMessage | StreamEvent { 249: if (message.type === 'assistant') { 250: return mapAssistantMessage(message, f, index, uuid) 251: } else { 252: return message 253: } 254: } 255: function dehydrateValue(s: unknown): unknown { 256: if (typeof s !== 'string') { 257: return s 258: } 259: const cwd = getCwd() 260: const configHome = getClaudeConfigHomeDir() 261: let s1 = s 262: .replace(/num_files="\d+"/g, 'num_files="[NUM]"') 263: .replace(/duration_ms="\d+"/g, 'duration_ms="[DURATION]"') 264: .replace(/cost_usd="\d+"/g, 'cost_usd="[COST]"') 265: .replaceAll(configHome, '[CONFIG_HOME]') 266: .replaceAll(cwd, '[CWD]') 267: .replace(/Available commands:.+/, 'Available commands: [COMMANDS]') 268: if (process.platform === 'win32') { 269: const cwdFwd = cwd.replaceAll('\\', '/') 270: const configHomeFwd = configHome.replaceAll('\\', '/') 271: // jsonStringify escapes \ to \\ - match paths embedded in JSON strings 272: const cwdJsonEscaped = jsonStringify(cwd).slice(1, -1) 273: const configHomeJsonEscaped = jsonStringify(configHome).slice(1, -1) 274: s1 = s1 275: .replaceAll(cwdJsonEscaped, '[CWD]') 276: .replaceAll(configHomeJsonEscaped, '[CONFIG_HOME]') 277: .replaceAll(cwdFwd, '[CWD]') 278: .replaceAll(configHomeFwd, '[CONFIG_HOME]') 279: } 280: // Normalize backslash path separators after placeholders so VCR fixture 281: // hashes match across platforms (e.g., [CWD]\foo\bar -> [CWD]/foo/bar) 282: // Handle both single backslashes and JSON-escaped double backslashes (\\) 283: s1 = s1 284: .replace(/\[CWD\][^\s"'<>]*/g, match => 285: match.replaceAll('\\\\', '/').replaceAll('\\', '/'), 286: ) 287: .replace(/\[CONFIG_HOME\][^\s"'<>]*/g, match => 288: match.replaceAll('\\\\', '/').replaceAll('\\', '/'), 289: ) 290: if (s1.includes('Files modified by user:')) { 291: return 'Files modified by user: [FILES]' 292: } 293: return s1 294: } 295: function hydrateValue(s: unknown): unknown { 296: if (typeof s !== 'string') { 297: return s 298: } 299: return s 300: .replaceAll('[NUM]', '1') 301: .replaceAll('[DURATION]', '100') 302: .replaceAll('[CONFIG_HOME]', getClaudeConfigHomeDir()) 303: .replaceAll('[CWD]', getCwd()) 304: } 305: export async function* withStreamingVCR( 306: messages: Message[], 307: f: () => AsyncGenerator< 308: StreamEvent | AssistantMessage | SystemAPIErrorMessage, 309: void 310: >, 311: ): AsyncGenerator< 312: StreamEvent | AssistantMessage | SystemAPIErrorMessage, 313: void 314: > { 315: if (!shouldUseVCR()) { 316: return yield* f() 317: } 318: const buffer: (StreamEvent | AssistantMessage | SystemAPIErrorMessage)[] = [] 319: const cachedBuffer = await withVCR(messages, async () => { 320: for await (const message of f()) { 321: buffer.push(message) 322: } 323: return buffer 324: }) 325: if (cachedBuffer.length > 0) { 326: yield* cachedBuffer 327: return 328: } 329: yield* buffer 330: } 331: export async function withTokenCountVCR( 332: messages: unknown[], 333: tools: unknown[], 334: f: () => Promise<number | null>, 335: ): Promise<number | null> { 336: const cwdSlug = getCwd().replace(/[^a-zA-Z0-9]/g, '-') 337: const dehydrated = ( 338: dehydrateValue(jsonStringify({ messages, tools })) as string 339: ) 340: .replaceAll(cwdSlug, '[CWD_SLUG]') 341: .replace( 342: /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, 343: '[UUID]', 344: ) 345: .replace(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z?/g, '[TIMESTAMP]') 346: const result = await withFixture(dehydrated, 'token-count', async () => ({ 347: tokenCount: await f(), 348: })) 349: return result.tokenCount 350: }

File: src/services/voice.ts

typescript 1: import { type ChildProcess, spawn, spawnSync } from 'child_process' 2: import { readFile } from 'fs/promises' 3: import { logForDebugging } from '../utils/debug.js' 4: import { isEnvTruthy, isRunningOnHomespace } from '../utils/envUtils.js' 5: import { logError } from '../utils/log.js' 6: import { getPlatform } from '../utils/platform.js' 7: type AudioNapi = typeof import('audio-capture-napi') 8: let audioNapi: AudioNapi | null = null 9: let audioNapiPromise: Promise<AudioNapi> | null = null 10: function loadAudioNapi(): Promise<AudioNapi> { 11: audioNapiPromise ??= (async () => { 12: const t0 = Date.now() 13: const mod = await import('audio-capture-napi') 14: mod.isNativeAudioAvailable() 15: audioNapi = mod 16: logForDebugging(`[voice] audio-capture-napi loaded in ${Date.now() - t0}ms`) 17: return mod 18: })() 19: return audioNapiPromise 20: } 21: const RECORDING_SAMPLE_RATE = 16000 22: const RECORDING_CHANNELS = 1 23: const SILENCE_DURATION_SECS = '2.0' 24: const SILENCE_THRESHOLD = '3%' 25: function hasCommand(cmd: string): boolean { 26: const result = spawnSync(cmd, ['--version'], { 27: stdio: 'ignore', 28: timeout: 3000, 29: }) 30: return result.error === undefined 31: } 32: type ArecordProbeResult = { ok: boolean; stderr: string } 33: let arecordProbe: Promise<ArecordProbeResult> | null = null 34: function probeArecord(): Promise<ArecordProbeResult> { 35: arecordProbe ??= new Promise(resolve => { 36: const child = spawn( 37: 'arecord', 38: [ 39: '-f', 40: 'S16_LE', 41: '-r', 42: String(RECORDING_SAMPLE_RATE), 43: '-c', 44: String(RECORDING_CHANNELS), 45: '-t', 46: 'raw', 47: '/dev/null', 48: ], 49: { stdio: ['ignore', 'ignore', 'pipe'] }, 50: ) 51: let stderr = '' 52: child.stderr?.on('data', (chunk: Buffer) => { 53: stderr += chunk.toString() 54: }) 55: const timer = setTimeout( 56: (c: ChildProcess, r: (v: ArecordProbeResult) => void) => { 57: c.kill('SIGTERM') 58: r({ ok: true, stderr: '' }) 59: }, 60: 150, 61: child, 62: resolve, 63: ) 64: child.once('close', code => { 65: clearTimeout(timer) 66: void resolve({ ok: code === 0, stderr: stderr.trim() }) 67: }) 68: child.once('error', () => { 69: clearTimeout(timer) 70: void resolve({ ok: false, stderr: 'arecord: command not found' }) 71: }) 72: }) 73: return arecordProbe 74: } 75: export function _resetArecordProbeForTesting(): void { 76: arecordProbe = null 77: } 78: let linuxAlsaCardsMemo: Promise<boolean> | null = null 79: function linuxHasAlsaCards(): Promise<boolean> { 80: linuxAlsaCardsMemo ??= readFile('/proc/asound/cards', 'utf8').then( 81: cards => { 82: const c = cards.trim() 83: return c !== '' && !c.includes('no soundcards') 84: }, 85: () => false, 86: ) 87: return linuxAlsaCardsMemo 88: } 89: export function _resetAlsaCardsForTesting(): void { 90: linuxAlsaCardsMemo = null 91: } 92: type PackageManagerInfo = { 93: cmd: string 94: args: string[] 95: displayCommand: string 96: } 97: function detectPackageManager(): PackageManagerInfo | null { 98: if (process.platform === 'darwin') { 99: if (hasCommand('brew')) { 100: return { 101: cmd: 'brew', 102: args: ['install', 'sox'], 103: displayCommand: 'brew install sox', 104: } 105: } 106: return null 107: } 108: if (process.platform === 'linux') { 109: if (hasCommand('apt-get')) { 110: return { 111: cmd: 'sudo', 112: args: ['apt-get', 'install', '-y', 'sox'], 113: displayCommand: 'sudo apt-get install sox', 114: } 115: } 116: if (hasCommand('dnf')) { 117: return { 118: cmd: 'sudo', 119: args: ['dnf', 'install', '-y', 'sox'], 120: displayCommand: 'sudo dnf install sox', 121: } 122: } 123: if (hasCommand('pacman')) { 124: return { 125: cmd: 'sudo', 126: args: ['pacman', '-S', '--noconfirm', 'sox'], 127: displayCommand: 'sudo pacman -S sox', 128: } 129: } 130: } 131: return null 132: } 133: export async function checkVoiceDependencies(): Promise<{ 134: available: boolean 135: missing: string[] 136: installCommand: string | null 137: }> { 138: const napi = await loadAudioNapi() 139: if (napi.isNativeAudioAvailable()) { 140: return { available: true, missing: [], installCommand: null } 141: } 142: if (process.platform === 'win32') { 143: return { 144: available: false, 145: missing: ['Voice mode requires the native audio module (not loaded)'], 146: installCommand: null, 147: } 148: } 149: if (process.platform === 'linux' && hasCommand('arecord')) { 150: return { available: true, missing: [], installCommand: null } 151: } 152: const missing: string[] = [] 153: if (!hasCommand('rec')) { 154: missing.push('sox (rec command)') 155: } 156: const pm = missing.length > 0 ? detectPackageManager() : null 157: return { 158: available: missing.length === 0, 159: missing, 160: installCommand: pm?.displayCommand ?? null, 161: } 162: } 163: export type RecordingAvailability = { 164: available: boolean 165: reason: string | null 166: } 167: export async function requestMicrophonePermission(): Promise<boolean> { 168: const napi = await loadAudioNapi() 169: if (!napi.isNativeAudioAvailable()) { 170: return true 171: } 172: const started = await startRecording( 173: _chunk => {}, 174: () => {}, 175: { silenceDetection: false }, 176: ) 177: if (started) { 178: stopRecording() 179: return true 180: } 181: return false 182: } 183: export async function checkRecordingAvailability(): Promise<RecordingAvailability> { 184: if (isRunningOnHomespace() || isEnvTruthy(process.env.CLAUDE_CODE_REMOTE)) { 185: return { 186: available: false, 187: reason: 188: 'Voice mode requires microphone access, but no audio device is available in this environment.\n\nTo use voice mode, run Claude Code locally instead.', 189: } 190: } 191: const napi = await loadAudioNapi() 192: if (napi.isNativeAudioAvailable()) { 193: return { available: true, reason: null } 194: } 195: if (process.platform === 'win32') { 196: return { 197: available: false, 198: reason: 199: 'Voice recording requires the native audio module, which could not be loaded.', 200: } 201: } 202: const wslNoAudioReason = 203: 'Voice mode could not access an audio device in WSL.\n\nWSL2 with WSLg (Windows 11) provides audio via PulseAudio — if you are on Windows 10 or WSL1, run Claude Code in native Windows instead.' 204: if (process.platform === 'linux' && hasCommand('arecord')) { 205: const probe = await probeArecord() 206: if (probe.ok) { 207: return { available: true, reason: null } 208: } 209: if (getPlatform() === 'wsl') { 210: return { available: false, reason: wslNoAudioReason } 211: } 212: logForDebugging(`[voice] arecord probe failed: ${probe.stderr}`) 213: } 214: if (!hasCommand('rec')) { 215: if (getPlatform() === 'wsl') { 216: return { available: false, reason: wslNoAudioReason } 217: } 218: const pm = detectPackageManager() 219: return { 220: available: false, 221: reason: pm 222: ? `Voice mode requires SoX for audio recording. Install it with: ${pm.displayCommand}` 223: : 'Voice mode requires SoX for audio recording. Install SoX manually:\n macOS: brew install sox\n Ubuntu/Debian: sudo apt-get install sox\n Fedora: sudo dnf install sox', 224: } 225: } 226: return { available: true, reason: null } 227: } 228: let activeRecorder: ChildProcess | null = null 229: let nativeRecordingActive = false 230: export async function startRecording( 231: onData: (chunk: Buffer) => void, 232: onEnd: () => void, 233: options?: { silenceDetection?: boolean }, 234: ): Promise<boolean> { 235: logForDebugging(`[voice] startRecording called, platform=${process.platform}`) 236: const napi = await loadAudioNapi() 237: const nativeAvailable = 238: napi.isNativeAudioAvailable() && 239: (process.platform !== 'linux' || (await linuxHasAlsaCards())) 240: const useSilenceDetection = options?.silenceDetection !== false 241: if (nativeAvailable) { 242: if (nativeRecordingActive || napi.isNativeRecordingActive()) { 243: napi.stopNativeRecording() 244: nativeRecordingActive = false 245: } 246: const started = napi.startNativeRecording( 247: (data: Buffer) => { 248: onData(data) 249: }, 250: () => { 251: if (useSilenceDetection) { 252: nativeRecordingActive = false 253: onEnd() 254: } 255: }, 256: ) 257: if (started) { 258: nativeRecordingActive = true 259: return true 260: } 261: } 262: if (process.platform === 'win32') { 263: logForDebugging('[voice] Windows native recording unavailable, no fallback') 264: return false 265: } 266: if ( 267: process.platform === 'linux' && 268: hasCommand('arecord') && 269: (await probeArecord()).ok 270: ) { 271: return startArecordRecording(onData, onEnd) 272: } 273: return startSoxRecording(onData, onEnd, options) 274: } 275: function startSoxRecording( 276: onData: (chunk: Buffer) => void, 277: onEnd: () => void, 278: options?: { silenceDetection?: boolean }, 279: ): boolean { 280: const useSilenceDetection = options?.silenceDetection !== false 281: const args = [ 282: '-q', 283: '--buffer', 284: '1024', 285: '-t', 286: 'raw', 287: '-r', 288: String(RECORDING_SAMPLE_RATE), 289: '-e', 290: 'signed', 291: '-b', 292: '16', 293: '-c', 294: String(RECORDING_CHANNELS), 295: '-', 296: ] 297: if (useSilenceDetection) { 298: args.push( 299: 'silence', 300: '1', 301: '0.1', 302: SILENCE_THRESHOLD, 303: '1', 304: SILENCE_DURATION_SECS, 305: SILENCE_THRESHOLD, 306: ) 307: } 308: const child = spawn('rec', args, { 309: stdio: ['pipe', 'pipe', 'pipe'], 310: }) 311: activeRecorder = child 312: child.stdout?.on('data', (chunk: Buffer) => { 313: onData(chunk) 314: }) 315: child.stderr?.on('data', () => {}) 316: child.on('close', () => { 317: activeRecorder = null 318: onEnd() 319: }) 320: child.on('error', err => { 321: logError(err) 322: activeRecorder = null 323: onEnd() 324: }) 325: return true 326: } 327: function startArecordRecording( 328: onData: (chunk: Buffer) => void, 329: onEnd: () => void, 330: ): boolean { 331: const args = [ 332: '-f', 333: 'S16_LE', 334: '-r', 335: String(RECORDING_SAMPLE_RATE), 336: '-c', 337: String(RECORDING_CHANNELS), 338: '-t', 339: 'raw', 340: '-q', 341: '-', 342: ] 343: const child = spawn('arecord', args, { 344: stdio: ['pipe', 'pipe', 'pipe'], 345: }) 346: activeRecorder = child 347: child.stdout?.on('data', (chunk: Buffer) => { 348: onData(chunk) 349: }) 350: child.stderr?.on('data', () => {}) 351: child.on('close', () => { 352: activeRecorder = null 353: onEnd() 354: }) 355: child.on('error', err => { 356: logError(err) 357: activeRecorder = null 358: onEnd() 359: }) 360: return true 361: } 362: export function stopRecording(): void { 363: if (nativeRecordingActive && audioNapi) { 364: audioNapi.stopNativeRecording() 365: nativeRecordingActive = false 366: return 367: } 368: if (activeRecorder) { 369: activeRecorder.kill('SIGTERM') 370: activeRecorder = null 371: } 372: }

File: src/services/voiceKeyterms.ts

typescript 1: import { basename } from 'path' 2: import { getProjectRoot } from '../bootstrap/state.js' 3: import { getBranch } from '../utils/git.js' 4: const GLOBAL_KEYTERMS: readonly string[] = [ 5: 'MCP', 6: 'symlink', 7: 'grep', 8: 'regex', 9: 'localhost', 10: 'codebase', 11: 'TypeScript', 12: 'JSON', 13: 'OAuth', 14: 'webhook', 15: 'gRPC', 16: 'dotfiles', 17: 'subagent', 18: 'worktree', 19: ] 20: export function splitIdentifier(name: string): string[] { 21: return name 22: .replace(/([a-z])([A-Z])/g, '$1 $2') 23: .split(/[-_./\s]+/) 24: .map(w => w.trim()) 25: .filter(w => w.length > 2 && w.length <= 20) 26: } 27: function fileNameWords(filePath: string): string[] { 28: const stem = basename(filePath).replace(/\.[^.]+$/, '') 29: return splitIdentifier(stem) 30: } 31: // ─── Public API ───────────────────────────────────────────────────── 32: const MAX_KEYTERMS = 50 33: /** 34: * Build a list of keyterms for the voice_stream STT endpoint. 35: * 36: * Combines hardcoded global coding terms with session context (project name, 37: * git branch, recent files) without any model calls. 38: */ 39: export async function getVoiceKeyterms( 40: recentFiles?: ReadonlySet<string>, 41: ): Promise<string[]> { 42: const terms = new Set<string>(GLOBAL_KEYTERMS) 43: // Project root basename as a single term — users say "claude CLI internal" 44: // as a phrase, not isolated words. Keeping the whole basename lets the 45: // STT's keyterm boosting match the phrase regardless of separator. 46: try { 47: const projectRoot = getProjectRoot() 48: if (projectRoot) { 49: const name = basename(projectRoot) 50: if (name.length > 2 && name.length <= 50) { 51: terms.add(name) 52: } 53: } 54: } catch { 55: } 56: try { 57: const branch = await getBranch() 58: if (branch) { 59: for (const word of splitIdentifier(branch)) { 60: terms.add(word) 61: } 62: } 63: } catch { 64: } 65: if (recentFiles) { 66: for (const filePath of recentFiles) { 67: if (terms.size >= MAX_KEYTERMS) break 68: for (const word of fileNameWords(filePath)) { 69: terms.add(word) 70: } 71: } 72: } 73: return [...terms].slice(0, MAX_KEYTERMS) 74: }

File: src/services/voiceStreamSTT.ts

typescript 1: import type { ClientRequest, IncomingMessage } from 'http' 2: import WebSocket from 'ws' 3: import { getOauthConfig } from '../constants/oauth.js' 4: import { 5: checkAndRefreshOAuthTokenIfNeeded, 6: getClaudeAIOAuthTokens, 7: isAnthropicAuthEnabled, 8: } from '../utils/auth.js' 9: import { logForDebugging } from '../utils/debug.js' 10: import { getUserAgent } from '../utils/http.js' 11: import { logError } from '../utils/log.js' 12: import { getWebSocketTLSOptions } from '../utils/mtls.js' 13: import { getWebSocketProxyAgent, getWebSocketProxyUrl } from '../utils/proxy.js' 14: import { jsonParse, jsonStringify } from '../utils/slowOperations.js' 15: const KEEPALIVE_MSG = '{"type":"KeepAlive"}' 16: const CLOSE_STREAM_MSG = '{"type":"CloseStream"}' 17: import { getFeatureValue_CACHED_MAY_BE_STALE } from './analytics/growthbook.js' 18: const VOICE_STREAM_PATH = '/api/ws/speech_to_text/voice_stream' 19: const KEEPALIVE_INTERVAL_MS = 8_000 20: export const FINALIZE_TIMEOUTS_MS = { 21: safety: 5_000, 22: noData: 1_500, 23: } 24: export type VoiceStreamCallbacks = { 25: onTranscript: (text: string, isFinal: boolean) => void 26: onError: (error: string, opts?: { fatal?: boolean }) => void 27: onClose: () => void 28: onReady: (connection: VoiceStreamConnection) => void 29: } 30: export type FinalizeSource = 31: | 'post_closestream_endpoint' 32: | 'no_data_timeout' 33: | 'safety_timeout' 34: | 'ws_close' 35: | 'ws_already_closed' 36: export type VoiceStreamConnection = { 37: send: (audioChunk: Buffer) => void 38: finalize: () => Promise<FinalizeSource> 39: close: () => void 40: isConnected: () => boolean 41: } 42: type VoiceStreamTranscriptText = { 43: type: 'TranscriptText' 44: data: string 45: } 46: type VoiceStreamTranscriptEndpoint = { 47: type: 'TranscriptEndpoint' 48: } 49: type VoiceStreamTranscriptError = { 50: type: 'TranscriptError' 51: error_code?: string 52: description?: string 53: } 54: type VoiceStreamMessage = 55: | VoiceStreamTranscriptText 56: | VoiceStreamTranscriptEndpoint 57: | VoiceStreamTranscriptError 58: | { type: 'error'; message?: string } 59: export function isVoiceStreamAvailable(): boolean { 60: if (!isAnthropicAuthEnabled()) { 61: return false 62: } 63: const tokens = getClaudeAIOAuthTokens() 64: return tokens !== null && tokens.accessToken !== null 65: } 66: export async function connectVoiceStream( 67: callbacks: VoiceStreamCallbacks, 68: options?: { language?: string; keyterms?: string[] }, 69: ): Promise<VoiceStreamConnection | null> { 70: await checkAndRefreshOAuthTokenIfNeeded() 71: const tokens = getClaudeAIOAuthTokens() 72: if (!tokens?.accessToken) { 73: logForDebugging('[voice_stream] No OAuth token available') 74: return null 75: } 76: const wsBaseUrl = 77: process.env.VOICE_STREAM_BASE_URL || 78: getOauthConfig() 79: .BASE_API_URL.replace('https://', 'wss://') 80: .replace('http://', 'ws://') 81: if (process.env.VOICE_STREAM_BASE_URL) { 82: logForDebugging( 83: `[voice_stream] Using VOICE_STREAM_BASE_URL override: ${process.env.VOICE_STREAM_BASE_URL}`, 84: ) 85: } 86: const params = new URLSearchParams({ 87: encoding: 'linear16', 88: sample_rate: '16000', 89: channels: '1', 90: endpointing_ms: '300', 91: utterance_end_ms: '1000', 92: language: options?.language ?? 'en', 93: }) 94: const isNova3 = getFeatureValue_CACHED_MAY_BE_STALE( 95: 'tengu_cobalt_frost', 96: false, 97: ) 98: if (isNova3) { 99: params.set('use_conversation_engine', 'true') 100: params.set('stt_provider', 'deepgram-nova3') 101: logForDebugging('[voice_stream] Nova 3 gate enabled (tengu_cobalt_frost)') 102: } 103: if (options?.keyterms?.length) { 104: for (const term of options.keyterms) { 105: params.append('keyterms', term) 106: } 107: } 108: const url = `${wsBaseUrl}${VOICE_STREAM_PATH}?${params.toString()}` 109: logForDebugging(`[voice_stream] Connecting to ${url}`) 110: const headers: Record<string, string> = { 111: Authorization: `Bearer ${tokens.accessToken}`, 112: 'User-Agent': getUserAgent(), 113: 'x-app': 'cli', 114: } 115: const tlsOptions = getWebSocketTLSOptions() 116: const wsOptions = 117: typeof Bun !== 'undefined' 118: ? { 119: headers, 120: proxy: getWebSocketProxyUrl(url), 121: tls: tlsOptions || undefined, 122: } 123: : { headers, agent: getWebSocketProxyAgent(url), ...tlsOptions } 124: const ws = new WebSocket(url, wsOptions) 125: let keepaliveTimer: ReturnType<typeof setInterval> | null = null 126: let connected = false 127: let finalized = false 128: let finalizing = false 129: let upgradeRejected = false 130: let resolveFinalize: ((source: FinalizeSource) => void) | null = null 131: let cancelNoDataTimer: (() => void) | null = null 132: const connection: VoiceStreamConnection = { 133: send(audioChunk: Buffer): void { 134: if (ws.readyState !== WebSocket.OPEN) { 135: return 136: } 137: if (finalized) { 138: logForDebugging( 139: `[voice_stream] Dropping audio chunk after CloseStream: ${String(audioChunk.length)} bytes`, 140: ) 141: return 142: } 143: logForDebugging( 144: `[voice_stream] Sending audio chunk: ${String(audioChunk.length)} bytes`, 145: ) 146: ws.send(Buffer.from(audioChunk)) 147: }, 148: finalize(): Promise<FinalizeSource> { 149: if (finalizing || finalized) { 150: return Promise.resolve('ws_already_closed') 151: } 152: finalizing = true 153: return new Promise<FinalizeSource>(resolve => { 154: const safetyTimer = setTimeout( 155: () => resolveFinalize?.('safety_timeout'), 156: FINALIZE_TIMEOUTS_MS.safety, 157: ) 158: const noDataTimer = setTimeout( 159: () => resolveFinalize?.('no_data_timeout'), 160: FINALIZE_TIMEOUTS_MS.noData, 161: ) 162: cancelNoDataTimer = () => { 163: clearTimeout(noDataTimer) 164: cancelNoDataTimer = null 165: } 166: resolveFinalize = (source: FinalizeSource) => { 167: clearTimeout(safetyTimer) 168: clearTimeout(noDataTimer) 169: resolveFinalize = null 170: cancelNoDataTimer = null 171: if (lastTranscriptText) { 172: logForDebugging( 173: `[voice_stream] Promoting unreported interim before ${source} resolve`, 174: ) 175: const t = lastTranscriptText 176: lastTranscriptText = '' 177: callbacks.onTranscript(t, true) 178: } 179: logForDebugging(`[voice_stream] Finalize resolved via ${source}`) 180: resolve(source) 181: } 182: // If the WebSocket is already closed, resolve immediately. 183: if ( 184: ws.readyState === WebSocket.CLOSED || 185: ws.readyState === WebSocket.CLOSING 186: ) { 187: resolveFinalize('ws_already_closed') 188: return 189: } 190: setTimeout(() => { 191: finalized = true 192: if (ws.readyState === WebSocket.OPEN) { 193: logForDebugging('[voice_stream] Sending CloseStream (finalize)') 194: ws.send(CLOSE_STREAM_MSG) 195: } 196: }, 0) 197: }) 198: }, 199: close(): void { 200: finalized = true 201: if (keepaliveTimer) { 202: clearInterval(keepaliveTimer) 203: keepaliveTimer = null 204: } 205: connected = false 206: if (ws.readyState === WebSocket.OPEN) { 207: ws.close() 208: } 209: }, 210: isConnected(): boolean { 211: return connected && ws.readyState === WebSocket.OPEN 212: }, 213: } 214: ws.on('open', () => { 215: logForDebugging('[voice_stream] WebSocket connected') 216: connected = true 217: logForDebugging('[voice_stream] Sending initial KeepAlive') 218: ws.send(KEEPALIVE_MSG) 219: keepaliveTimer = setInterval( 220: ws => { 221: if (ws.readyState === WebSocket.OPEN) { 222: logForDebugging('[voice_stream] Sending periodic KeepAlive') 223: ws.send(KEEPALIVE_MSG) 224: } 225: }, 226: KEEPALIVE_INTERVAL_MS, 227: ws, 228: ) 229: callbacks.onReady(connection) 230: }) 231: let lastTranscriptText = '' 232: ws.on('message', (raw: Buffer | string) => { 233: const text = raw.toString() 234: logForDebugging( 235: `[voice_stream] Message received (${String(text.length)} chars): ${text.slice(0, 200)}`, 236: ) 237: let msg: VoiceStreamMessage 238: try { 239: msg = jsonParse(text) as VoiceStreamMessage 240: } catch { 241: return 242: } 243: switch (msg.type) { 244: case 'TranscriptText': { 245: const transcript = msg.data 246: logForDebugging(`[voice_stream] TranscriptText: "${transcript ?? ''}"`) 247: if (finalized) { 248: cancelNoDataTimer?.() 249: } 250: if (transcript) { 251: if (!isNova3 && lastTranscriptText) { 252: const prev = lastTranscriptText.trimStart() 253: const next = transcript.trimStart() 254: if ( 255: prev && 256: next && 257: !next.startsWith(prev) && 258: !prev.startsWith(next) 259: ) { 260: logForDebugging( 261: `[voice_stream] Auto-finalizing previous segment (new segment detected): "${lastTranscriptText}"`, 262: ) 263: callbacks.onTranscript(lastTranscriptText, true) 264: } 265: } 266: lastTranscriptText = transcript 267: callbacks.onTranscript(transcript, false) 268: } 269: break 270: } 271: case 'TranscriptEndpoint': { 272: logForDebugging( 273: `[voice_stream] TranscriptEndpoint received, lastTranscriptText="${lastTranscriptText}"`, 274: ) 275: const finalText = lastTranscriptText 276: lastTranscriptText = '' 277: if (finalText) { 278: callbacks.onTranscript(finalText, true) 279: } 280: // When TranscriptEndpoint arrives after CloseStream was sent, 281: // the server has flushed its final transcript — nothing more is 282: // coming. Resolve finalize now so the caller reads the 283: // accumulated buffer immediately (~300ms) instead of waiting 284: // for the WebSocket close event (~3-5s of server teardown). 285: // `finalized` (not `finalizing`) is the right gate: it flips 286: // inside the setTimeout(0) that actually sends CloseStream, so 287: // a TranscriptEndpoint that races the deferred send still waits. 288: if (finalized) { 289: resolveFinalize?.('post_closestream_endpoint') 290: } 291: break 292: } 293: case 'TranscriptError': { 294: const desc = 295: msg.description ?? msg.error_code ?? 'unknown transcription error' 296: logForDebugging(`[voice_stream] TranscriptError: ${desc}`) 297: if (!finalizing) { 298: callbacks.onError(desc) 299: } 300: break 301: } 302: case 'error': { 303: const errorDetail = msg.message ?? jsonStringify(msg) 304: logForDebugging(`[voice_stream] Server error: ${errorDetail}`) 305: if (!finalizing) { 306: callbacks.onError(errorDetail) 307: } 308: break 309: } 310: default: 311: break 312: } 313: }) 314: ws.on('close', (code, reason) => { 315: const reasonStr = reason?.toString() ?? '' 316: logForDebugging( 317: `[voice_stream] WebSocket closed: code=${String(code)} reason="${reasonStr}"`, 318: ) 319: connected = false 320: if (keepaliveTimer) { 321: clearInterval(keepaliveTimer) 322: keepaliveTimer = null 323: } 324: // If the server closed the connection before sending TranscriptEndpoint, 325: // promote the last interim transcript to final so no text is lost. 326: if (lastTranscriptText) { 327: logForDebugging( 328: '[voice_stream] Promoting unreported interim transcript to final on close', 329: ) 330: const finalText = lastTranscriptText 331: lastTranscriptText = '' 332: callbacks.onTranscript(finalText, true) 333: } 334: // During finalize, suppress onError — the session already delivered 335: // whatever it had. useVoice's onError path wipes accumulatedRef, 336: resolveFinalize?.('ws_close') 337: if (!finalizing && !upgradeRejected && code !== 1000 && code !== 1005) { 338: callbacks.onError( 339: `Connection closed: code ${String(code)}${reasonStr ? ` — ${reasonStr}` : ''}`, 340: ) 341: } 342: callbacks.onClose() 343: }) 344: ws.on('unexpected-response', (req: ClientRequest, res: IncomingMessage) => { 345: const status = res.statusCode ?? 0 346: if (status === 101) { 347: logForDebugging( 348: '[voice_stream] unexpected-response fired with 101; ignoring', 349: ) 350: return 351: } 352: logForDebugging( 353: `[voice_stream] Upgrade rejected: status=${String(status)} cf-mitigated=${String(res.headers['cf-mitigated'])} cf-ray=${String(res.headers['cf-ray'])}`, 354: ) 355: upgradeRejected = true 356: res.resume() 357: req.destroy() 358: if (finalizing) return 359: callbacks.onError( 360: `WebSocket upgrade rejected with HTTP ${String(status)}`, 361: { fatal: status >= 400 && status < 500 }, 362: ) 363: }) 364: ws.on('error', (err: Error) => { 365: logError(err) 366: logForDebugging(`[voice_stream] WebSocket error: ${err.message}`) 367: if (!finalizing) { 368: callbacks.onError(`Voice stream connection error: ${err.message}`) 369: } 370: }) 371: return connection 372: }

File: src/skills/bundled/batch.ts

typescript 1: import { AGENT_TOOL_NAME } from '../../tools/AgentTool/constants.js' 2: import { ASK_USER_QUESTION_TOOL_NAME } from '../../tools/AskUserQuestionTool/prompt.js' 3: import { ENTER_PLAN_MODE_TOOL_NAME } from '../../tools/EnterPlanModeTool/constants.js' 4: import { EXIT_PLAN_MODE_TOOL_NAME } from '../../tools/ExitPlanModeTool/constants.js' 5: import { SKILL_TOOL_NAME } from '../../tools/SkillTool/constants.js' 6: import { getIsGit } from '../../utils/git.js' 7: import { registerBundledSkill } from '../bundledSkills.js' 8: const MIN_AGENTS = 5 9: const MAX_AGENTS = 30 10: const WORKER_INSTRUCTIONS = `After you finish implementing the change: 11: 1. **Simplify** — Invoke the \`${SKILL_TOOL_NAME}\` tool with \`skill: "simplify"\` to review and clean up your changes. 12: 2. **Run unit tests** — Run the project's test suite (check for package.json scripts, Makefile targets, or common commands like \`npm test\`, \`bun test\`, \`pytest\`, \`go test\`). If tests fail, fix them. 13: 3. **Test end-to-end** — Follow the e2e test recipe from the coordinator's prompt (below). If the recipe says to skip e2e for this unit, skip it. 14: 4. **Commit and push** — Commit all changes with a clear message, push the branch, and create a PR with \`gh pr create\`. Use a descriptive title. If \`gh\` is not available or the push fails, note it in your final message. 15: 5. **Report** — End with a single line: \`PR: <url>\` so the coordinator can track it. If no PR was created, end with \`PR: none — <reason>\`.` 16: function buildPrompt(instruction: string): string { 17: return `# Batch: Parallel Work Orchestration 18: You are orchestrating a large, parallelizable change across this codebase. 19: ## User Instruction 20: ${instruction} 21: ## Phase 1: Research and Plan (Plan Mode) 22: Call the \`${ENTER_PLAN_MODE_TOOL_NAME}\` tool now to enter plan mode, then: 23: 1. **Understand the scope.** Launch one or more subagents (in the foreground — you need their results) to deeply research what this instruction touches. Find all the files, patterns, and call sites that need to change. Understand the existing conventions so the migration is consistent. 24: 2. **Decompose into independent units.** Break the work into ${MIN_AGENTS}–${MAX_AGENTS} self-contained units. Each unit must: 25: - Be independently implementable in an isolated git worktree (no shared state with sibling units) 26: - Be mergeable on its own without depending on another unit's PR landing first 27: - Be roughly uniform in size (split large units, merge trivial ones) 28: Scale the count to the actual work: few files → closer to ${MIN_AGENTS}; hundreds of files → closer to ${MAX_AGENTS}. Prefer per-directory or per-module slicing over arbitrary file lists. 29: 3. **Determine the e2e test recipe.** Figure out how a worker can verify its change actually works end-to-end — not just that unit tests pass. Look for: 30: - A \`claude-in-chrome\` skill or browser-automation tool (for UI changes: click through the affected flow, screenshot the result) 31: - A \`tmux\` or CLI-verifier skill (for CLI changes: launch the app interactively, exercise the changed behavior) 32: - A dev-server + curl pattern (for API changes: start the server, hit the affected endpoints) 33: - An existing e2e/integration test suite the worker can run 34: If you cannot find a concrete e2e path, use the \`${ASK_USER_QUESTION_TOOL_NAME}\` tool to ask the user how to verify this change end-to-end. Offer 2–3 specific options based on what you found (e.g., "Screenshot via chrome extension", "Run \`bun run dev\` and curl the endpoint", "No e2e — unit tests are sufficient"). Do not skip this — the workers cannot ask the user themselves. 35: Write the recipe as a short, concrete set of steps that a worker can execute autonomously. Include any setup (start a dev server, build first) and the exact command/interaction to verify. 36: 4. **Write the plan.** In your plan file, include: 37: - A summary of what you found during research 38: - A numbered list of work units — for each: a short title, the list of files/directories it covers, and a one-line description of the change 39: - The e2e test recipe (or "skip e2e because …" if the user chose that) 40: - The exact worker instructions you will give each agent (the shared template) 41: 5. Call \`${EXIT_PLAN_MODE_TOOL_NAME}\` to present the plan for approval. 42: ## Phase 2: Spawn Workers (After Plan Approval) 43: Once the plan is approved, spawn one background agent per work unit using the \`${AGENT_TOOL_NAME}\` tool. **All agents must use \`isolation: "worktree"\` and \`run_in_background: true\`.** Launch them all in a single message block so they run in parallel. 44: For each agent, the prompt must be fully self-contained. Include: 45: - The overall goal (the user's instruction) 46: - This unit's specific task (title, file list, change description — copied verbatim from your plan) 47: - Any codebase conventions you discovered that the worker needs to follow 48: - The e2e test recipe from your plan (or "skip e2e because …") 49: - The worker instructions below, copied verbatim: 50: \`\`\` 51: ${WORKER_INSTRUCTIONS} 52: \`\`\` 53: Use \`subagent_type: "general-purpose"\` unless a more specific agent type fits. 54: ## Phase 3: Track Progress 55: After launching all workers, render an initial status table: 56: | # | Unit | Status | PR | 57: |---|------|--------|----| 58: | 1 | <title> | running | — | 59: | 2 | <title> | running | — | 60: As background-agent completion notifications arrive, parse the \`PR: <url>\` line from each agent's result and re-render the table with updated status (\`done\` / \`failed\`) and PR links. Keep a brief failure note for any agent that did not produce a PR. 61: When all agents have reported, render the final table and a one-line summary (e.g., "22/24 units landed as PRs"). 62: ` 63: } 64: const NOT_A_GIT_REPO_MESSAGE = `This is not a git repository. The \`/batch\` command requires a git repo because it spawns agents in isolated git worktrees and creates PRs from each. Initialize a repo first, or run this from inside an existing one.` 65: const MISSING_INSTRUCTION_MESSAGE = `Provide an instruction describing the batch change you want to make. 66: Examples: 67: /batch migrate from react to vue 68: /batch replace all uses of lodash with native equivalents 69: /batch add type annotations to all untyped function parameters` 70: export function registerBatchSkill(): void { 71: registerBundledSkill({ 72: name: 'batch', 73: description: 74: 'Research and plan a large-scale change, then execute it in parallel across 5–30 isolated worktree agents that each open a PR.', 75: whenToUse: 76: 'Use when the user wants to make a sweeping, mechanical change across many files (migrations, refactors, bulk renames) that can be decomposed into independent parallel units.', 77: argumentHint: '<instruction>', 78: userInvocable: true, 79: disableModelInvocation: true, 80: async getPromptForCommand(args) { 81: const instruction = args.trim() 82: if (!instruction) { 83: return [{ type: 'text', text: MISSING_INSTRUCTION_MESSAGE }] 84: } 85: const isGit = await getIsGit() 86: if (!isGit) { 87: return [{ type: 'text', text: NOT_A_GIT_REPO_MESSAGE }] 88: } 89: return [{ type: 'text', text: buildPrompt(instruction) }] 90: }, 91: }) 92: }

File: src/skills/bundled/claudeApi.ts

typescript 1: import { readdir } from 'fs/promises' 2: import { getCwd } from '../../utils/cwd.js' 3: import { registerBundledSkill } from '../bundledSkills.js' 4: type SkillContent = typeof import('./claudeApiContent.js') 5: type DetectedLanguage = 6: | 'python' 7: | 'typescript' 8: | 'java' 9: | 'go' 10: | 'ruby' 11: | 'csharp' 12: | 'php' 13: | 'curl' 14: const LANGUAGE_INDICATORS: Record<DetectedLanguage, string[]> = { 15: python: ['.py', 'requirements.txt', 'pyproject.toml', 'setup.py', 'Pipfile'], 16: typescript: ['.ts', '.tsx', 'tsconfig.json', 'package.json'], 17: java: ['.java', 'pom.xml', 'build.gradle'], 18: go: ['.go', 'go.mod'], 19: ruby: ['.rb', 'Gemfile'], 20: csharp: ['.cs', '.csproj'], 21: php: ['.php', 'composer.json'], 22: curl: [], 23: } 24: async function detectLanguage(): Promise<DetectedLanguage | null> { 25: const cwd = getCwd() 26: let entries: string[] 27: try { 28: entries = await readdir(cwd) 29: } catch { 30: return null 31: } 32: for (const [lang, indicators] of Object.entries(LANGUAGE_INDICATORS) as [ 33: DetectedLanguage, 34: string[], 35: ][]) { 36: if (indicators.length === 0) continue 37: for (const indicator of indicators) { 38: if (indicator.startsWith('.')) { 39: if (entries.some(e => e.endsWith(indicator))) return lang 40: } else { 41: if (entries.includes(indicator)) return lang 42: } 43: } 44: } 45: return null 46: } 47: function getFilesForLanguage( 48: lang: DetectedLanguage, 49: content: SkillContent, 50: ): string[] { 51: return Object.keys(content.SKILL_FILES).filter( 52: path => path.startsWith(`${lang}/`) || path.startsWith('shared/'), 53: ) 54: } 55: function processContent(md: string, content: SkillContent): string { 56: let out = md 57: let prev 58: do { 59: prev = out 60: out = out.replace(/<!--[\s\S]*?-->\n?/g, '') 61: } while (out !== prev) 62: out = out.replace( 63: /\{\{(\w+)\}\}/g, 64: (match, key: string) => 65: (content.SKILL_MODEL_VARS as Record<string, string>)[key] ?? match, 66: ) 67: return out 68: } 69: function buildInlineReference( 70: filePaths: string[], 71: content: SkillContent, 72: ): string { 73: const sections: string[] = [] 74: for (const filePath of filePaths.sort()) { 75: const md = content.SKILL_FILES[filePath] 76: if (!md) continue 77: sections.push( 78: `<doc path="${filePath}">\n${processContent(md, content).trim()}\n</doc>`, 79: ) 80: } 81: return sections.join('\n\n') 82: } 83: const INLINE_READING_GUIDE = `## Reference Documentation 84: The relevant documentation for your detected language is included below in \`<doc>\` tags. Each tag has a \`path\` attribute showing its original file path. Use this to find the right section: 85: ### Quick Task Reference 86: **Single text classification/summarization/extraction/Q&A:** 87: → Refer to \`{lang}/claude-api/README.md\` 88: **Chat UI or real-time response display:** 89: → Refer to \`{lang}/claude-api/README.md\` + \`{lang}/claude-api/streaming.md\` 90: **Long-running conversations (may exceed context window):** 91: → Refer to \`{lang}/claude-api/README.md\` — see Compaction section 92: **Prompt caching / optimize caching / "why is my cache hit rate low":** 93: → Refer to \`shared/prompt-caching.md\` + \`{lang}/claude-api/README.md\` (Prompt Caching section) 94: **Function calling / tool use / agents:** 95: → Refer to \`{lang}/claude-api/README.md\` + \`shared/tool-use-concepts.md\` + \`{lang}/claude-api/tool-use.md\` 96: **Batch processing (non-latency-sensitive):** 97: → Refer to \`{lang}/claude-api/README.md\` + \`{lang}/claude-api/batches.md\` 98: **File uploads across multiple requests:** 99: → Refer to \`{lang}/claude-api/README.md\` + \`{lang}/claude-api/files-api.md\` 100: **Agent with built-in tools (file/web/terminal) (Python & TypeScript only):** 101: → Refer to \`{lang}/agent-sdk/README.md\` + \`{lang}/agent-sdk/patterns.md\` 102: **Error handling:** 103: → Refer to \`shared/error-codes.md\` 104: **Latest docs via WebFetch:** 105: → Refer to \`shared/live-sources.md\` for URLs` 106: function buildPrompt( 107: lang: DetectedLanguage | null, 108: args: string, 109: content: SkillContent, 110: ): string { 111: const cleanPrompt = processContent(content.SKILL_PROMPT, content) 112: const readingGuideIdx = cleanPrompt.indexOf('## Reading Guide') 113: const basePrompt = 114: readingGuideIdx !== -1 115: ? cleanPrompt.slice(0, readingGuideIdx).trimEnd() 116: : cleanPrompt 117: const parts: string[] = [basePrompt] 118: if (lang) { 119: const filePaths = getFilesForLanguage(lang, content) 120: const readingGuide = INLINE_READING_GUIDE.replace(/\{lang\}/g, lang) 121: parts.push(readingGuide) 122: parts.push( 123: '---\n\n## Included Documentation\n\n' + 124: buildInlineReference(filePaths, content), 125: ) 126: } else { 127: parts.push(INLINE_READING_GUIDE.replace(/\{lang\}/g, 'unknown')) 128: parts.push( 129: 'No project language was auto-detected. Ask the user which language they are using, then refer to the matching docs below.', 130: ) 131: parts.push( 132: '---\n\n## Included Documentation\n\n' + 133: buildInlineReference(Object.keys(content.SKILL_FILES), content), 134: ) 135: } 136: const webFetchIdx = cleanPrompt.indexOf('## When to Use WebFetch') 137: if (webFetchIdx !== -1) { 138: parts.push(cleanPrompt.slice(webFetchIdx).trimEnd()) 139: } 140: if (args) { 141: parts.push(`## User Request\n\n${args}`) 142: } 143: return parts.join('\n\n') 144: } 145: export function registerClaudeApiSkill(): void { 146: registerBundledSkill({ 147: name: 'claude-api', 148: description: 149: 'Build apps with the Claude API or Anthropic SDK.\n' + 150: 'TRIGGER when: code imports `anthropic`/`@anthropic-ai/sdk`/`claude_agent_sdk`, or user asks to use Claude API, Anthropic SDKs, or Agent SDK.\n' + 151: 'DO NOT TRIGGER when: code imports `openai`/other AI SDK, general programming, or ML/data-science tasks.', 152: allowedTools: ['Read', 'Grep', 'Glob', 'WebFetch'], 153: userInvocable: true, 154: async getPromptForCommand(args) { 155: const content = await import('./claudeApiContent.js') 156: const lang = await detectLanguage() 157: const prompt = buildPrompt(lang, args, content) 158: return [{ type: 'text', text: prompt }] 159: }, 160: }) 161: }

File: src/skills/bundled/claudeApiContent.ts

typescript 1: import csharpClaudeApi from './claude-api/csharp/claude-api.md' 2: import curlExamples from './claude-api/curl/examples.md' 3: import goClaudeApi from './claude-api/go/claude-api.md' 4: import javaClaudeApi from './claude-api/java/claude-api.md' 5: import phpClaudeApi from './claude-api/php/claude-api.md' 6: import pythonAgentSdkPatterns from './claude-api/python/agent-sdk/patterns.md' 7: import pythonAgentSdkReadme from './claude-api/python/agent-sdk/README.md' 8: import pythonClaudeApiBatches from './claude-api/python/claude-api/batches.md' 9: import pythonClaudeApiFilesApi from './claude-api/python/claude-api/files-api.md' 10: import pythonClaudeApiReadme from './claude-api/python/claude-api/README.md' 11: import pythonClaudeApiStreaming from './claude-api/python/claude-api/streaming.md' 12: import pythonClaudeApiToolUse from './claude-api/python/claude-api/tool-use.md' 13: import rubyClaudeApi from './claude-api/ruby/claude-api.md' 14: import skillPrompt from './claude-api/SKILL.md' 15: import sharedErrorCodes from './claude-api/shared/error-codes.md' 16: import sharedLiveSources from './claude-api/shared/live-sources.md' 17: import sharedModels from './claude-api/shared/models.md' 18: import sharedPromptCaching from './claude-api/shared/prompt-caching.md' 19: import sharedToolUseConcepts from './claude-api/shared/tool-use-concepts.md' 20: import typescriptAgentSdkPatterns from './claude-api/typescript/agent-sdk/patterns.md' 21: import typescriptAgentSdkReadme from './claude-api/typescript/agent-sdk/README.md' 22: import typescriptClaudeApiBatches from './claude-api/typescript/claude-api/batches.md' 23: import typescriptClaudeApiFilesApi from './claude-api/typescript/claude-api/files-api.md' 24: import typescriptClaudeApiReadme from './claude-api/typescript/claude-api/README.md' 25: import typescriptClaudeApiStreaming from './claude-api/typescript/claude-api/streaming.md' 26: import typescriptClaudeApiToolUse from './claude-api/typescript/claude-api/tool-use.md' 27: export const SKILL_MODEL_VARS = { 28: OPUS_ID: 'claude-opus-4-6', 29: OPUS_NAME: 'Claude Opus 4.6', 30: SONNET_ID: 'claude-sonnet-4-6', 31: SONNET_NAME: 'Claude Sonnet 4.6', 32: HAIKU_ID: 'claude-haiku-4-5', 33: HAIKU_NAME: 'Claude Haiku 4.5', 34: PREV_SONNET_ID: 'claude-sonnet-4-5', 35: } satisfies Record<string, string> 36: export const SKILL_PROMPT: string = skillPrompt 37: export const SKILL_FILES: Record<string, string> = { 38: 'csharp/claude-api.md': csharpClaudeApi, 39: 'curl/examples.md': curlExamples, 40: 'go/claude-api.md': goClaudeApi, 41: 'java/claude-api.md': javaClaudeApi, 42: 'php/claude-api.md': phpClaudeApi, 43: 'python/agent-sdk/README.md': pythonAgentSdkReadme, 44: 'python/agent-sdk/patterns.md': pythonAgentSdkPatterns, 45: 'python/claude-api/README.md': pythonClaudeApiReadme, 46: 'python/claude-api/batches.md': pythonClaudeApiBatches, 47: 'python/claude-api/files-api.md': pythonClaudeApiFilesApi, 48: 'python/claude-api/streaming.md': pythonClaudeApiStreaming, 49: 'python/claude-api/tool-use.md': pythonClaudeApiToolUse, 50: 'ruby/claude-api.md': rubyClaudeApi, 51: 'shared/error-codes.md': sharedErrorCodes, 52: 'shared/live-sources.md': sharedLiveSources, 53: 'shared/models.md': sharedModels, 54: 'shared/prompt-caching.md': sharedPromptCaching, 55: 'shared/tool-use-concepts.md': sharedToolUseConcepts, 56: 'typescript/agent-sdk/README.md': typescriptAgentSdkReadme, 57: 'typescript/agent-sdk/patterns.md': typescriptAgentSdkPatterns, 58: 'typescript/claude-api/README.md': typescriptClaudeApiReadme, 59: 'typescript/claude-api/batches.md': typescriptClaudeApiBatches, 60: 'typescript/claude-api/files-api.md': typescriptClaudeApiFilesApi, 61: 'typescript/claude-api/streaming.md': typescriptClaudeApiStreaming, 62: 'typescript/claude-api/tool-use.md': typescriptClaudeApiToolUse, 63: }

File: src/skills/bundled/claudeInChrome.ts

typescript 1: import { BROWSER_TOOLS } from '@ant/claude-for-chrome-mcp' 2: import { BASE_CHROME_PROMPT } from '../../utils/claudeInChrome/prompt.js' 3: import { shouldAutoEnableClaudeInChrome } from '../../utils/claudeInChrome/setup.js' 4: import { registerBundledSkill } from '../bundledSkills.js' 5: const CLAUDE_IN_CHROME_MCP_TOOLS = BROWSER_TOOLS.map( 6: tool => `mcp__claude-in-chrome__${tool.name}`, 7: ) 8: const SKILL_ACTIVATION_MESSAGE = ` 9: Now that this skill is invoked, you have access to Chrome browser automation tools. You can now use the mcp__claude-in-chrome__* tools to interact with web pages. 10: IMPORTANT: Start by calling mcp__claude-in-chrome__tabs_context_mcp to get information about the user's current browser tabs. 11: ` 12: export function registerClaudeInChromeSkill(): void { 13: registerBundledSkill({ 14: name: 'claude-in-chrome', 15: description: 16: 'Automates your Chrome browser to interact with web pages - clicking elements, filling forms, capturing screenshots, reading console logs, and navigating sites. Opens pages in new tabs within your existing Chrome session. Requires site-level permissions before executing (configured in the extension).', 17: whenToUse: 18: 'When the user wants to interact with web pages, automate browser tasks, capture screenshots, read console logs, or perform any browser-based actions. Always invoke BEFORE attempting to use any mcp__claude-in-chrome__* tools.', 19: allowedTools: CLAUDE_IN_CHROME_MCP_TOOLS, 20: userInvocable: true, 21: isEnabled: () => shouldAutoEnableClaudeInChrome(), 22: async getPromptForCommand(args) { 23: let prompt = `${BASE_CHROME_PROMPT}\n${SKILL_ACTIVATION_MESSAGE}` 24: if (args) { 25: prompt += `\n## Task\n\n${args}` 26: } 27: return [{ type: 'text', text: prompt }] 28: }, 29: }) 30: }

File: src/skills/bundled/debug.ts

typescript 1: import { open, stat } from 'fs/promises' 2: import { CLAUDE_CODE_GUIDE_AGENT_TYPE } from 'src/tools/AgentTool/built-in/claudeCodeGuideAgent.js' 3: import { getSettingsFilePathForSource } from 'src/utils/settings/settings.js' 4: import { enableDebugLogging, getDebugLogPath } from '../../utils/debug.js' 5: import { errorMessage, isENOENT } from '../../utils/errors.js' 6: import { formatFileSize } from '../../utils/format.js' 7: import { registerBundledSkill } from '../bundledSkills.js' 8: const DEFAULT_DEBUG_LINES_READ = 20 9: const TAIL_READ_BYTES = 64 * 1024 10: export function registerDebugSkill(): void { 11: registerBundledSkill({ 12: name: 'debug', 13: description: 14: process.env.USER_TYPE === 'ant' 15: ? 'Debug your current Claude Code session by reading the session debug log. Includes all event logging' 16: : 'Enable debug logging for this session and help diagnose issues', 17: allowedTools: ['Read', 'Grep', 'Glob'], 18: argumentHint: '[issue description]', 19: disableModelInvocation: true, 20: userInvocable: true, 21: async getPromptForCommand(args) { 22: const wasAlreadyLogging = enableDebugLogging() 23: const debugLogPath = getDebugLogPath() 24: let logInfo: string 25: try { 26: const stats = await stat(debugLogPath) 27: const readSize = Math.min(stats.size, TAIL_READ_BYTES) 28: const startOffset = stats.size - readSize 29: const fd = await open(debugLogPath, 'r') 30: try { 31: const { buffer, bytesRead } = await fd.read({ 32: buffer: Buffer.alloc(readSize), 33: position: startOffset, 34: }) 35: const tail = buffer 36: .toString('utf-8', 0, bytesRead) 37: .split('\n') 38: .slice(-DEFAULT_DEBUG_LINES_READ) 39: .join('\n') 40: logInfo = `Log size: ${formatFileSize(stats.size)}\n\n### Last ${DEFAULT_DEBUG_LINES_READ} lines\n\n\`\`\`\n${tail}\n\`\`\`` 41: } finally { 42: await fd.close() 43: } 44: } catch (e) { 45: logInfo = isENOENT(e) 46: ? 'No debug log exists yet — logging was just enabled.' 47: : `Failed to read last ${DEFAULT_DEBUG_LINES_READ} lines of debug log: ${errorMessage(e)}` 48: } 49: const justEnabledSection = wasAlreadyLogging 50: ? '' 51: : ` 52: ## Debug Logging Just Enabled 53: Debug logging was OFF for this session until now. Nothing prior to this /debug invocation was captured. 54: Tell the user that debug logging is now active at \`${debugLogPath}\`, ask them to reproduce the issue, then re-read the log. If they can't reproduce, they can also restart with \`claude --debug\` to capture logs from startup. 55: ` 56: const prompt = `# Debug Skill 57: Help the user debug an issue they're encountering in this current Claude Code session. 58: ${justEnabledSection} 59: ## Session Debug Log 60: The debug log for the current session is at: \`${debugLogPath}\` 61: ${logInfo} 62: For additional context, grep for [ERROR] and [WARN] lines across the full file. 63: ## Issue Description 64: ${args || 'The user did not describe a specific issue. Read the debug log and summarize any errors, warnings, or notable issues.'} 65: ## Settings 66: Remember that settings are in: 67: * user - ${getSettingsFilePathForSource('userSettings')} 68: * project - ${getSettingsFilePathForSource('projectSettings')} 69: * local - ${getSettingsFilePathForSource('localSettings')} 70: ## Instructions 71: 1. Review the user's issue description 72: 2. The last ${DEFAULT_DEBUG_LINES_READ} lines show the debug file format. Look for [ERROR] and [WARN] entries, stack traces, and failure patterns across the file 73: 3. Consider launching the ${CLAUDE_CODE_GUIDE_AGENT_TYPE} subagent to understand the relevant Claude Code features 74: 4. Explain what you found in plain language 75: 5. Suggest concrete fixes or next steps 76: ` 77: return [{ type: 'text', text: prompt }] 78: }, 79: }) 80: }

File: src/skills/bundled/index.ts

typescript 1: import { feature } from 'bun:bundle' 2: import { shouldAutoEnableClaudeInChrome } from 'src/utils/claudeInChrome/setup.js' 3: import { registerBatchSkill } from './batch.js' 4: import { registerClaudeInChromeSkill } from './claudeInChrome.js' 5: import { registerDebugSkill } from './debug.js' 6: import { registerKeybindingsSkill } from './keybindings.js' 7: import { registerLoremIpsumSkill } from './loremIpsum.js' 8: import { registerRememberSkill } from './remember.js' 9: import { registerSimplifySkill } from './simplify.js' 10: import { registerSkillifySkill } from './skillify.js' 11: import { registerStuckSkill } from './stuck.js' 12: import { registerUpdateConfigSkill } from './updateConfig.js' 13: import { registerVerifySkill } from './verify.js' 14: export function initBundledSkills(): void { 15: registerUpdateConfigSkill() 16: registerKeybindingsSkill() 17: registerVerifySkill() 18: registerDebugSkill() 19: registerLoremIpsumSkill() 20: registerSkillifySkill() 21: registerRememberSkill() 22: registerSimplifySkill() 23: registerBatchSkill() 24: registerStuckSkill() 25: if (feature('KAIROS') || feature('KAIROS_DREAM')) { 26: const { registerDreamSkill } = require('./dream.js') 27: registerDreamSkill() 28: } 29: if (feature('REVIEW_ARTIFACT')) { 30: const { registerHunterSkill } = require('./hunter.js') 31: registerHunterSkill() 32: } 33: if (feature('AGENT_TRIGGERS')) { 34: const { registerLoopSkill } = require('./loop.js') 35: registerLoopSkill() 36: } 37: if (feature('AGENT_TRIGGERS_REMOTE')) { 38: const { 39: registerScheduleRemoteAgentsSkill, 40: } = require('./scheduleRemoteAgents.js') 41: registerScheduleRemoteAgentsSkill() 42: } 43: if (feature('BUILDING_CLAUDE_APPS')) { 44: const { registerClaudeApiSkill } = require('./claudeApi.js') 45: registerClaudeApiSkill() 46: } 47: if (shouldAutoEnableClaudeInChrome()) { 48: registerClaudeInChromeSkill() 49: } 50: if (feature('RUN_SKILL_GENERATOR')) { 51: const { registerRunSkillGeneratorSkill } = require('./runSkillGenerator.js') 52: registerRunSkillGeneratorSkill() 53: } 54: }

File: src/skills/bundled/keybindings.ts

typescript 1: import { DEFAULT_BINDINGS } from '../../keybindings/defaultBindings.js' 2: import { isKeybindingCustomizationEnabled } from '../../keybindings/loadUserBindings.js' 3: import { 4: MACOS_RESERVED, 5: NON_REBINDABLE, 6: TERMINAL_RESERVED, 7: } from '../../keybindings/reservedShortcuts.js' 8: import type { KeybindingsSchemaType } from '../../keybindings/schema.js' 9: import { 10: KEYBINDING_ACTIONS, 11: KEYBINDING_CONTEXT_DESCRIPTIONS, 12: KEYBINDING_CONTEXTS, 13: } from '../../keybindings/schema.js' 14: import { jsonStringify } from '../../utils/slowOperations.js' 15: import { registerBundledSkill } from '../bundledSkills.js' 16: function generateContextsTable(): string { 17: return markdownTable( 18: ['Context', 'Description'], 19: KEYBINDING_CONTEXTS.map(ctx => [ 20: `\`${ctx}\``, 21: KEYBINDING_CONTEXT_DESCRIPTIONS[ctx], 22: ]), 23: ) 24: } 25: function generateActionsTable(): string { 26: const actionInfo: Record<string, { keys: string[]; context: string }> = {} 27: for (const block of DEFAULT_BINDINGS) { 28: for (const [key, action] of Object.entries(block.bindings)) { 29: if (action) { 30: if (!actionInfo[action]) { 31: actionInfo[action] = { keys: [], context: block.context } 32: } 33: actionInfo[action].keys.push(key) 34: } 35: } 36: } 37: return markdownTable( 38: ['Action', 'Default Key(s)', 'Context'], 39: KEYBINDING_ACTIONS.map(action => { 40: const info = actionInfo[action] 41: const keys = info ? info.keys.map(k => `\`${k}\``).join(', ') : '(none)' 42: const context = info ? info.context : inferContextFromAction(action) 43: return [`\`${action}\``, keys, context] 44: }), 45: ) 46: } 47: function inferContextFromAction(action: string): string { 48: const prefix = action.split(':')[0] 49: const prefixToContext: Record<string, string> = { 50: app: 'Global', 51: history: 'Global or Chat', 52: chat: 'Chat', 53: autocomplete: 'Autocomplete', 54: confirm: 'Confirmation', 55: tabs: 'Tabs', 56: transcript: 'Transcript', 57: historySearch: 'HistorySearch', 58: task: 'Task', 59: theme: 'ThemePicker', 60: help: 'Help', 61: attachments: 'Attachments', 62: footer: 'Footer', 63: messageSelector: 'MessageSelector', 64: diff: 'DiffDialog', 65: modelPicker: 'ModelPicker', 66: select: 'Select', 67: permission: 'Confirmation', 68: } 69: return prefixToContext[prefix ?? ''] ?? 'Unknown' 70: } 71: function generateReservedShortcuts(): string { 72: const lines: string[] = [] 73: lines.push('### Non-rebindable (errors)') 74: for (const s of NON_REBINDABLE) { 75: lines.push(`- \`${s.key}\` — ${s.reason}`) 76: } 77: lines.push('') 78: lines.push('### Terminal reserved (errors/warnings)') 79: for (const s of TERMINAL_RESERVED) { 80: lines.push( 81: `- \`${s.key}\` — ${s.reason} (${s.severity === 'error' ? 'will not work' : 'may conflict'})`, 82: ) 83: } 84: lines.push('') 85: lines.push('### macOS reserved (errors)') 86: for (const s of MACOS_RESERVED) { 87: lines.push(`- \`${s.key}\` — ${s.reason}`) 88: } 89: return lines.join('\n') 90: } 91: const FILE_FORMAT_EXAMPLE: KeybindingsSchemaType = { 92: $schema: 'https://www.schemastore.org/claude-code-keybindings.json', 93: $docs: 'https://code.claude.com/docs/en/keybindings', 94: bindings: [ 95: { 96: context: 'Chat', 97: bindings: { 98: 'ctrl+e': 'chat:externalEditor', 99: }, 100: }, 101: ], 102: } 103: const UNBIND_EXAMPLE: KeybindingsSchemaType['bindings'][number] = { 104: context: 'Chat', 105: bindings: { 106: 'ctrl+s': null, 107: }, 108: } 109: const REBIND_EXAMPLE: KeybindingsSchemaType['bindings'][number] = { 110: context: 'Chat', 111: bindings: { 112: 'ctrl+g': null, 113: 'ctrl+e': 'chat:externalEditor', 114: }, 115: } 116: const CHORD_EXAMPLE: KeybindingsSchemaType['bindings'][number] = { 117: context: 'Global', 118: bindings: { 119: 'ctrl+k ctrl+t': 'app:toggleTodos', 120: }, 121: } 122: const SECTION_INTRO = [ 123: '# Keybindings Skill', 124: '', 125: 'Create or modify `~/.claude/keybindings.json` to customize keyboard shortcuts.', 126: '', 127: '## CRITICAL: Read Before Write', 128: '', 129: '**Always read `~/.claude/keybindings.json` first** (it may not exist yet). Merge changes with existing bindings — never replace the entire file.', 130: '', 131: '- Use **Edit** tool for modifications to existing files', 132: '- Use **Write** tool only if the file does not exist yet', 133: ].join('\n') 134: const SECTION_FILE_FORMAT = [ 135: '## File Format', 136: '', 137: '```json', 138: jsonStringify(FILE_FORMAT_EXAMPLE, null, 2), 139: '```', 140: '', 141: 'Always include the `$schema` and `$docs` fields.', 142: ].join('\n') 143: const SECTION_KEYSTROKE_SYNTAX = [ 144: '## Keystroke Syntax', 145: '', 146: '**Modifiers** (combine with `+`):', 147: '- `ctrl` (alias: `control`)', 148: '- `alt` (aliases: `opt`, `option`) — note: `alt` and `meta` are identical in terminals', 149: '- `shift`', 150: '- `meta` (aliases: `cmd`, `command`)', 151: '', 152: '**Special keys**: `escape`/`esc`, `enter`/`return`, `tab`, `space`, `backspace`, `delete`, `up`, `down`, `left`, `right`', 153: '', 154: '**Chords**: Space-separated keystrokes, e.g. `ctrl+k ctrl+s` (1-second timeout between keystrokes)', 155: '', 156: '**Examples**: `ctrl+shift+p`, `alt+enter`, `ctrl+k ctrl+n`', 157: ].join('\n') 158: const SECTION_UNBINDING = [ 159: '## Unbinding Default Shortcuts', 160: '', 161: 'Set a key to `null` to remove its default binding:', 162: '', 163: '```json', 164: jsonStringify(UNBIND_EXAMPLE, null, 2), 165: '```', 166: ].join('\n') 167: const SECTION_INTERACTION = [ 168: '## How User Bindings Interact with Defaults', 169: '', 170: '- User bindings are **additive** — they are appended after the default bindings', 171: '- To **move** a binding to a different key: unbind the old key (`null`) AND add the new binding', 172: "- A context only needs to appear in the user's file if they want to change something in that context", 173: ].join('\n') 174: const SECTION_COMMON_PATTERNS = [ 175: '## Common Patterns', 176: '', 177: '### Rebind a key', 178: 'To change the external editor shortcut from `ctrl+g` to `ctrl+e`:', 179: '```json', 180: jsonStringify(REBIND_EXAMPLE, null, 2), 181: '```', 182: '', 183: '### Add a chord binding', 184: '```json', 185: jsonStringify(CHORD_EXAMPLE, null, 2), 186: '```', 187: ].join('\n') 188: const SECTION_BEHAVIORAL_RULES = [ 189: '## Behavioral Rules', 190: '', 191: '1. Only include contexts the user wants to change (minimal overrides)', 192: '2. Validate that actions and contexts are from the known lists below', 193: '3. Warn the user proactively if they choose a key that conflicts with reserved shortcuts or common tools like tmux (`ctrl+b`) and screen (`ctrl+a`)', 194: '4. When adding a new binding for an existing action, the new binding is additive (existing default still works unless explicitly unbound)', 195: '5. To fully replace a default binding, unbind the old key AND add the new one', 196: ].join('\n') 197: const SECTION_DOCTOR = [ 198: '## Validation with /doctor', 199: '', 200: 'The `/doctor` command includes a "Keybinding Configuration Issues" section that validates `~/.claude/keybindings.json`.', 201: '', 202: '### Common Issues and Fixes', 203: '', 204: markdownTable( 205: ['Issue', 'Cause', 'Fix'], 206: [ 207: [ 208: '`keybindings.json must have a "bindings" array`', 209: 'Missing wrapper object', 210: 'Wrap bindings in `{ "bindings": [...] }`', 211: ], 212: [ 213: '`"bindings" must be an array`', 214: '`bindings` is not an array', 215: 'Set `"bindings"` to an array: `[{ context: ..., bindings: ... }]`', 216: ], 217: [ 218: '`Unknown context "X"`', 219: 'Typo or invalid context name', 220: 'Use exact context names from the Available Contexts table', 221: ], 222: [ 223: '`Duplicate key "X" in Y bindings`', 224: 'Same key defined twice in one context', 225: 'Remove the duplicate; JSON uses only the last value', 226: ], 227: [ 228: '`"X" may not work: ...`', 229: 'Key conflicts with terminal/OS reserved shortcut', 230: 'Choose a different key (see Reserved Shortcuts section)', 231: ], 232: [ 233: '`Could not parse keystroke "X"`', 234: 'Invalid key syntax', 235: 'Check syntax: use `+` between modifiers, valid key names', 236: ], 237: [ 238: '`Invalid action for "X"`', 239: 'Action value is not a string or null', 240: 'Actions must be strings like `"app:help"` or `null` to unbind', 241: ], 242: ], 243: ), 244: '', 245: '### Example /doctor Output', 246: '', 247: '```', 248: 'Keybinding Configuration Issues', 249: 'Location: ~/.claude/keybindings.json', 250: ' └ [Error] Unknown context "chat"', 251: ' → Valid contexts: Global, Chat, Autocomplete, ...', 252: ' └ [Warning] "ctrl+c" may not work: Terminal interrupt (SIGINT)', 253: '```', 254: '', 255: '**Errors** prevent bindings from working and must be fixed. **Warnings** indicate potential conflicts but the binding may still work.', 256: ].join('\n') 257: export function registerKeybindingsSkill(): void { 258: registerBundledSkill({ 259: name: 'keybindings-help', 260: description: 261: 'Use when the user wants to customize keyboard shortcuts, rebind keys, add chord bindings, or modify ~/.claude/keybindings.json. Examples: "rebind ctrl+s", "add a chord shortcut", "change the submit key", "customize keybindings".', 262: allowedTools: ['Read'], 263: userInvocable: false, 264: isEnabled: isKeybindingCustomizationEnabled, 265: async getPromptForCommand(args) { 266: const contextsTable = generateContextsTable() 267: const actionsTable = generateActionsTable() 268: const reservedShortcuts = generateReservedShortcuts() 269: const sections = [ 270: SECTION_INTRO, 271: SECTION_FILE_FORMAT, 272: SECTION_KEYSTROKE_SYNTAX, 273: SECTION_UNBINDING, 274: SECTION_INTERACTION, 275: SECTION_COMMON_PATTERNS, 276: SECTION_BEHAVIORAL_RULES, 277: SECTION_DOCTOR, 278: `## Reserved Shortcuts\n\n${reservedShortcuts}`, 279: `## Available Contexts\n\n${contextsTable}`, 280: `## Available Actions\n\n${actionsTable}`, 281: ] 282: if (args) { 283: sections.push(`## User Request\n\n${args}`) 284: } 285: return [{ type: 'text', text: sections.join('\n\n') }] 286: }, 287: }) 288: } 289: function markdownTable(headers: string[], rows: string[][]): string { 290: const separator = headers.map(() => '---') 291: return [ 292: `| ${headers.join(' | ')} |`, 293: `| ${separator.join(' | ')} |`, 294: ...rows.map(row => `| ${row.join(' | ')} |`), 295: ].join('\n') 296: }

File: src/skills/bundled/loop.ts

typescript 1: import { 2: CRON_CREATE_TOOL_NAME, 3: CRON_DELETE_TOOL_NAME, 4: DEFAULT_MAX_AGE_DAYS, 5: isKairosCronEnabled, 6: } from '../../tools/ScheduleCronTool/prompt.js' 7: import { registerBundledSkill } from '../bundledSkills.js' 8: const DEFAULT_INTERVAL = '10m' 9: const USAGE_MESSAGE = `Usage: /loop [interval] <prompt> 10: Run a prompt or slash command on a recurring interval. 11: Intervals: Ns, Nm, Nh, Nd (e.g. 5m, 30m, 2h, 1d). Minimum granularity is 1 minute. 12: If no interval is specified, defaults to ${DEFAULT_INTERVAL}. 13: Examples: 14: /loop 5m /babysit-prs 15: /loop 30m check the deploy 16: /loop 1h /standup 1 17: /loop check the deploy (defaults to ${DEFAULT_INTERVAL}) 18: /loop check the deploy every 20m` 19: function buildPrompt(args: string): string { 20: return `# /loop — schedule a recurring prompt 21: Parse the input below into \`[interval] <prompt…>\` and schedule it with ${CRON_CREATE_TOOL_NAME}. 22: ## Parsing (in priority order) 23: 1. **Leading token**: if the first whitespace-delimited token matches \`^\\d+[smhd]$\` (e.g. \`5m\`, \`2h\`), that's the interval; the rest is the prompt. 24: 2. **Trailing "every" clause**: otherwise, if the input ends with \`every <N><unit>\` or \`every <N> <unit-word>\` (e.g. \`every 20m\`, \`every 5 minutes\`, \`every 2 hours\`), extract that as the interval and strip it from the prompt. Only match when what follows "every" is a time expression — \`check every PR\` has no interval. 25: 3. **Default**: otherwise, interval is \`${DEFAULT_INTERVAL}\` and the entire input is the prompt. 26: If the resulting prompt is empty, show usage \`/loop [interval] <prompt>\` and stop — do not call ${CRON_CREATE_TOOL_NAME}. 27: Examples: 28: - \`5m /babysit-prs\` → interval \`5m\`, prompt \`/babysit-prs\` (rule 1) 29: - \`check the deploy every 20m\` → interval \`20m\`, prompt \`check the deploy\` (rule 2) 30: - \`run tests every 5 minutes\` → interval \`5m\`, prompt \`run tests\` (rule 2) 31: - \`check the deploy\` → interval \`${DEFAULT_INTERVAL}\`, prompt \`check the deploy\` (rule 3) 32: - \`check every PR\` → interval \`${DEFAULT_INTERVAL}\`, prompt \`check every PR\` (rule 3 — "every" not followed by time) 33: - \`5m\` → empty prompt → show usage 34: ## Interval → cron 35: Supported suffixes: \`s\` (seconds, rounded up to nearest minute, min 1), \`m\` (minutes), \`h\` (hours), \`d\` (days). Convert: 36: | Interval pattern | Cron expression | Notes | 37: |-----------------------|---------------------|------------------------------------------| 38: | \`Nm\` where N ≤ 59 | \`*/N * * * *\` | every N minutes | 39: | \`Nm\` where N ≥ 60 | \`0 */H * * *\` | round to hours (H = N/60, must divide 24)| 40: | \`Nh\` where N ≤ 23 | \`0 */N * * *\` | every N hours | 41: | \`Nd\` | \`0 0 */N * *\` | every N days at midnight local | 42: | \`Ns\` | treat as \`ceil(N/60)m\` | cron minimum granularity is 1 minute | 43: **If the interval doesn't cleanly divide its unit** (e.g. \`7m\` → \`*/7 * * * *\` gives uneven gaps at :56→:00; \`90m\` → 1.5h which cron can't express), pick the nearest clean interval and tell the user what you rounded to before scheduling. 44: ## Action 45: 1. Call ${CRON_CREATE_TOOL_NAME} with: 46: - \`cron\`: the expression from the table above 47: - \`prompt\`: the parsed prompt from above, verbatim (slash commands are passed through unchanged) 48: - \`recurring\`: \`true\` 49: 2. Briefly confirm: what's scheduled, the cron expression, the human-readable cadence, that recurring tasks auto-expire after ${DEFAULT_MAX_AGE_DAYS} days, and that they can cancel sooner with ${CRON_DELETE_TOOL_NAME} (include the job ID). 50: 3. **Then immediately execute the parsed prompt now** — don't wait for the first cron fire. If it's a slash command, invoke it via the Skill tool; otherwise act on it directly. 51: ## Input 52: ${args}` 53: } 54: export function registerLoopSkill(): void { 55: registerBundledSkill({ 56: name: 'loop', 57: description: 58: 'Run a prompt or slash command on a recurring interval (e.g. /loop 5m /foo, defaults to 10m)', 59: whenToUse: 60: 'When the user wants to set up a recurring task, poll for status, or run something repeatedly on an interval (e.g. "check the deploy every 5 minutes", "keep running /babysit-prs"). Do NOT invoke for one-off tasks.', 61: argumentHint: '[interval] <prompt>', 62: userInvocable: true, 63: isEnabled: isKairosCronEnabled, 64: async getPromptForCommand(args) { 65: const trimmed = args.trim() 66: if (!trimmed) { 67: return [{ type: 'text', text: USAGE_MESSAGE }] 68: } 69: return [{ type: 'text', text: buildPrompt(trimmed) }] 70: }, 71: }) 72: }

File: src/skills/bundled/loremIpsum.ts

typescript 1: import { registerBundledSkill } from '../bundledSkills.js' 2: const ONE_TOKEN_WORDS = [ 3: 'the', 4: 'a', 5: 'an', 6: 'I', 7: 'you', 8: 'he', 9: 'she', 10: 'it', 11: 'we', 12: 'they', 13: 'me', 14: 'him', 15: 'her', 16: 'us', 17: 'them', 18: 'my', 19: 'your', 20: 'his', 21: 'its', 22: 'our', 23: 'this', 24: 'that', 25: 'what', 26: 'who', 27: 'is', 28: 'are', 29: 'was', 30: 'were', 31: 'be', 32: 'been', 33: 'have', 34: 'has', 35: 'had', 36: 'do', 37: 'does', 38: 'did', 39: 'will', 40: 'would', 41: 'can', 42: 'could', 43: 'may', 44: 'might', 45: 'must', 46: 'shall', 47: 'should', 48: 'make', 49: 'made', 50: 'get', 51: 'got', 52: 'go', 53: 'went', 54: 'come', 55: 'came', 56: 'see', 57: 'saw', 58: 'know', 59: 'take', 60: 'think', 61: 'look', 62: 'want', 63: 'use', 64: 'find', 65: 'give', 66: 'tell', 67: 'work', 68: 'call', 69: 'try', 70: 'ask', 71: 'need', 72: 'feel', 73: 'seem', 74: 'leave', 75: 'put', 76: 'time', 77: 'year', 78: 'day', 79: 'way', 80: 'man', 81: 'thing', 82: 'life', 83: 'hand', 84: 'part', 85: 'place', 86: 'case', 87: 'point', 88: 'fact', 89: 'good', 90: 'new', 91: 'first', 92: 'last', 93: 'long', 94: 'great', 95: 'little', 96: 'own', 97: 'other', 98: 'old', 99: 'right', 100: 'big', 101: 'high', 102: 'small', 103: 'large', 104: 'next', 105: 'early', 106: 'young', 107: 'few', 108: 'public', 109: 'bad', 110: 'same', 111: 'able', 112: 'in', 113: 'on', 114: 'at', 115: 'to', 116: 'for', 117: 'of', 118: 'with', 119: 'from', 120: 'by', 121: 'about', 122: 'like', 123: 'through', 124: 'over', 125: 'before', 126: 'between', 127: 'under', 128: 'since', 129: 'without', 130: 'and', 131: 'or', 132: 'but', 133: 'if', 134: 'than', 135: 'because', 136: 'as', 137: 'until', 138: 'while', 139: 'so', 140: 'though', 141: 'both', 142: 'each', 143: 'when', 144: 'where', 145: 'why', 146: 'how', 147: 'not', 148: 'now', 149: 'just', 150: 'more', 151: 'also', 152: 'here', 153: 'there', 154: 'then', 155: 'only', 156: 'very', 157: 'well', 158: 'back', 159: 'still', 160: 'even', 161: 'much', 162: 'too', 163: 'such', 164: 'never', 165: 'again', 166: 'most', 167: 'once', 168: 'off', 169: 'away', 170: 'down', 171: 'out', 172: 'up', 173: 'test', 174: 'code', 175: 'data', 176: 'file', 177: 'line', 178: 'text', 179: 'word', 180: 'number', 181: 'system', 182: 'program', 183: 'set', 184: 'run', 185: 'value', 186: 'name', 187: 'type', 188: 'state', 189: 'end', 190: 'start', 191: ] 192: function generateLoremIpsum(targetTokens: number): string { 193: let tokens = 0 194: let result = '' 195: while (tokens < targetTokens) { 196: // Sentence: 10-20 words 197: const sentenceLength = 10 + Math.floor(Math.random() * 11) 198: let wordsInSentence = 0 199: for (let i = 0; i < sentenceLength && tokens < targetTokens; i++) { 200: const word = 201: ONE_TOKEN_WORDS[Math.floor(Math.random() * ONE_TOKEN_WORDS.length)] 202: result += word 203: tokens++ 204: wordsInSentence++ 205: if (i === sentenceLength - 1 || tokens >= targetTokens) { 206: result += '. ' 207: } else { 208: result += ' ' 209: } 210: } 211: // Paragraph break every 5-8 sentences (roughly 20% chance per sentence) 212: if (wordsInSentence > 0 && Math.random() < 0.2 && tokens < targetTokens) { 213: result += '\n\n' 214: } 215: } 216: return result.trim() 217: } 218: export function registerLoremIpsumSkill(): void { 219: if (process.env.USER_TYPE !== 'ant') { 220: return 221: } 222: registerBundledSkill({ 223: name: 'lorem-ipsum', 224: description: 225: 'Generate filler text for long context testing. Specify token count as argument (e.g., /lorem-ipsum 50000). Outputs approximately the requested number of tokens. Ant-only.', 226: argumentHint: '[token_count]', 227: userInvocable: true, 228: async getPromptForCommand(args) { 229: const parsed = parseInt(args) 230: if (args && (isNaN(parsed) || parsed <= 0)) { 231: return [ 232: { 233: type: 'text', 234: text: 'Invalid token count. Please provide a positive number (e.g., /lorem-ipsum 10000).', 235: }, 236: ] 237: } 238: const targetTokens = parsed || 10000 239: const cappedTokens = Math.min(targetTokens, 500_000) 240: if (cappedTokens < targetTokens) { 241: return [ 242: { 243: type: 'text', 244: text: `Requested ${targetTokens} tokens, but capped at 500,000 for safety.\n\n${generateLoremIpsum(cappedTokens)}`, 245: }, 246: ] 247: } 248: const loremText = generateLoremIpsum(cappedTokens) 249: return [ 250: { 251: type: 'text', 252: text: loremText, 253: }, 254: ] 255: }, 256: }) 257: }

File: src/skills/bundled/remember.ts

typescript 1: import { isAutoMemoryEnabled } from '../../memdir/paths.js' 2: import { registerBundledSkill } from '../bundledSkills.js' 3: export function registerRememberSkill(): void { 4: if (process.env.USER_TYPE !== 'ant') { 5: return 6: } 7: const SKILL_PROMPT = `# Memory Review 8: ## Goal 9: Review the user's memory landscape and produce a clear report of proposed changes, grouped by action type. Do NOT apply changes — present proposals for user approval. 10: ## Steps 11: ### 1. Gather all memory layers 12: Read CLAUDE.md and CLAUDE.local.md from the project root (if they exist). Your auto-memory content is already in your system prompt — review it there. Note which team memory sections exist, if any. 13: **Success criteria**: You have the contents of all memory layers and can compare them. 14: ### 2. Classify each auto-memory entry 15: For each substantive entry in auto-memory, determine the best destination: 16: | Destination | What belongs there | Examples | 17: |---|---|---| 18: | **CLAUDE.md** | Project conventions and instructions for Claude that all contributors should follow | "use bun not npm", "API routes use kebab-case", "test command is bun test", "prefer functional style" | 19: | **CLAUDE.local.md** | Personal instructions for Claude specific to this user, not applicable to other contributors | "I prefer concise responses", "always explain trade-offs", "don't auto-commit", "run tests before committing" | 20: | **Team memory** | Org-wide knowledge that applies across repositories (only if team memory is configured) | "deploy PRs go through #deploy-queue", "staging is at staging.internal", "platform team owns infra" | 21: | **Stay in auto-memory** | Working notes, temporary context, or entries that don't clearly fit elsewhere | Session-specific observations, uncertain patterns | 22: **Important distinctions:** 23: - CLAUDE.md and CLAUDE.local.md contain instructions for Claude, not user preferences for external tools (editor theme, IDE keybindings, etc. don't belong in either) 24: - Workflow practices (PR conventions, merge strategies, branch naming) are ambiguous — ask the user whether they're personal or team-wide 25: - When unsure, ask rather than guess 26: **Success criteria**: Each entry has a proposed destination or is flagged as ambiguous. 27: ### 3. Identify cleanup opportunities 28: Scan across all layers for: 29: - **Duplicates**: Auto-memory entries already captured in CLAUDE.md or CLAUDE.local.md → propose removing from auto-memory 30: - **Outdated**: CLAUDE.md or CLAUDE.local.md entries contradicted by newer auto-memory entries → propose updating the older layer 31: - **Conflicts**: Contradictions between any two layers → propose resolution, noting which is more recent 32: **Success criteria**: All cross-layer issues identified. 33: ### 4. Present the report 34: Output a structured report grouped by action type: 35: 1. **Promotions** — entries to move, with destination and rationale 36: 2. **Cleanup** — duplicates, outdated entries, conflicts to resolve 37: 3. **Ambiguous** — entries where you need the user's input on destination 38: 4. **No action needed** — brief note on entries that should stay put 39: If auto-memory is empty, say so and offer to review CLAUDE.md for cleanup. 40: **Success criteria**: User can review and approve/reject each proposal individually. 41: ## Rules 42: - Present ALL proposals before making any changes 43: - Do NOT modify files without explicit user approval 44: - Do NOT create new files unless the target doesn't exist yet 45: - Ask about ambiguous entries — don't guess 46: ` 47: registerBundledSkill({ 48: name: 'remember', 49: description: 50: 'Review auto-memory entries and propose promotions to CLAUDE.md, CLAUDE.local.md, or shared memory. Also detects outdated, conflicting, and duplicate entries across memory layers.', 51: whenToUse: 52: 'Use when the user wants to review, organize, or promote their auto-memory entries. Also useful for cleaning up outdated or conflicting entries across CLAUDE.md, CLAUDE.local.md, and auto-memory.', 53: userInvocable: true, 54: isEnabled: () => isAutoMemoryEnabled(), 55: async getPromptForCommand(args) { 56: let prompt = SKILL_PROMPT 57: if (args) { 58: prompt += `\n## Additional context from user\n\n${args}` 59: } 60: return [{ type: 'text', text: prompt }] 61: }, 62: }) 63: }

File: src/skills/bundled/scheduleRemoteAgents.ts

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

File: src/skills/bundled/simplify.ts

typescript 1: import { AGENT_TOOL_NAME } from '../../tools/AgentTool/constants.js' 2: import { registerBundledSkill } from '../bundledSkills.js' 3: const SIMPLIFY_PROMPT = `# Simplify: Code Review and Cleanup 4: Review all changed files for reuse, quality, and efficiency. Fix any issues found. 5: ## Phase 1: Identify Changes 6: Run \`git diff\` (or \`git diff HEAD\` if there are staged changes) to see what changed. If there are no git changes, review the most recently modified files that the user mentioned or that you edited earlier in this conversation. 7: ## Phase 2: Launch Three Review Agents in Parallel 8: Use the ${AGENT_TOOL_NAME} tool to launch all three agents concurrently in a single message. Pass each agent the full diff so it has the complete context. 9: ### Agent 1: Code Reuse Review 10: For each change: 11: 1. **Search for existing utilities and helpers** that could replace newly written code. Look for similar patterns elsewhere in the codebase — common locations are utility directories, shared modules, and files adjacent to the changed ones. 12: 2. **Flag any new function that duplicates existing functionality.** Suggest the existing function to use instead. 13: 3. **Flag any inline logic that could use an existing utility** — hand-rolled string manipulation, manual path handling, custom environment checks, ad-hoc type guards, and similar patterns are common candidates. 14: ### Agent 2: Code Quality Review 15: Review the same changes for hacky patterns: 16: 1. **Redundant state**: state that duplicates existing state, cached values that could be derived, observers/effects that could be direct calls 17: 2. **Parameter sprawl**: adding new parameters to a function instead of generalizing or restructuring existing ones 18: 3. **Copy-paste with slight variation**: near-duplicate code blocks that should be unified with a shared abstraction 19: 4. **Leaky abstractions**: exposing internal details that should be encapsulated, or breaking existing abstraction boundaries 20: 5. **Stringly-typed code**: using raw strings where constants, enums (string unions), or branded types already exist in the codebase 21: 6. **Unnecessary JSX nesting**: wrapper Boxes/elements that add no layout value — check if inner component props (flexShrink, alignItems, etc.) already provide the needed behavior 22: 7. **Unnecessary comments**: comments explaining WHAT the code does (well-named identifiers already do that), narrating the change, or referencing the task/caller — delete; keep only non-obvious WHY (hidden constraints, subtle invariants, workarounds) 23: ### Agent 3: Efficiency Review 24: Review the same changes for efficiency: 25: 1. **Unnecessary work**: redundant computations, repeated file reads, duplicate network/API calls, N+1 patterns 26: 2. **Missed concurrency**: independent operations run sequentially when they could run in parallel 27: 3. **Hot-path bloat**: new blocking work added to startup or per-request/per-render hot paths 28: 4. **Recurring no-op updates**: state/store updates inside polling loops, intervals, or event handlers that fire unconditionally — add a change-detection guard so downstream consumers aren't notified when nothing changed. Also: if a wrapper function takes an updater/reducer callback, verify it honors same-reference returns (or whatever the "no change" signal is) — otherwise callers' early-return no-ops are silently defeated 29: 5. **Unnecessary existence checks**: pre-checking file/resource existence before operating (TOCTOU anti-pattern) — operate directly and handle the error 30: 6. **Memory**: unbounded data structures, missing cleanup, event listener leaks 31: 7. **Overly broad operations**: reading entire files when only a portion is needed, loading all items when filtering for one 32: ## Phase 3: Fix Issues 33: Wait for all three agents to complete. Aggregate their findings and fix each issue directly. If a finding is a false positive or not worth addressing, note it and move on — do not argue with the finding, just skip it. 34: When done, briefly summarize what was fixed (or confirm the code was already clean). 35: ` 36: export function registerSimplifySkill(): void { 37: registerBundledSkill({ 38: name: 'simplify', 39: description: 40: 'Review changed code for reuse, quality, and efficiency, then fix any issues found.', 41: userInvocable: true, 42: async getPromptForCommand(args) { 43: let prompt = SIMPLIFY_PROMPT 44: if (args) { 45: prompt += `\n\n## Additional Focus\n\n${args}` 46: } 47: return [{ type: 'text', text: prompt }] 48: }, 49: }) 50: }

File: src/skills/bundled/skillify.ts

typescript 1: import { getSessionMemoryContent } from '../../services/SessionMemory/sessionMemoryUtils.js' 2: import type { Message } from '../../types/message.js' 3: import { getMessagesAfterCompactBoundary } from '../../utils/messages.js' 4: import { registerBundledSkill } from '../bundledSkills.js' 5: function extractUserMessages(messages: Message[]): string[] { 6: return messages 7: .filter((m): m is Extract<typeof m, { type: 'user' }> => m.type === 'user') 8: .map(m => { 9: const content = m.message.content 10: if (typeof content === 'string') return content 11: return content 12: .filter( 13: (b): b is Extract<typeof b, { type: 'text' }> => b.type === 'text', 14: ) 15: .map(b => b.text) 16: .join('\n') 17: }) 18: .filter(text => text.trim().length > 0) 19: } 20: const SKILLIFY_PROMPT = `# Skillify {{userDescriptionBlock}} 21: You are capturing this session's repeatable process as a reusable skill. 22: ## Your Session Context 23: Here is the session memory summary: 24: <session_memory> 25: {{sessionMemory}} 26: </session_memory> 27: Here are the user's messages during this session. Pay attention to how they steered the process, to help capture their detailed preferences in the skill: 28: <user_messages> 29: {{userMessages}} 30: </user_messages> 31: ## Your Task 32: ### Step 1: Analyze the Session 33: Before asking any questions, analyze the session to identify: 34: - What repeatable process was performed 35: - What the inputs/parameters were 36: - The distinct steps (in order) 37: - The success artifacts/criteria (e.g. not just "writing code," but "an open PR with CI fully passing") for each step 38: - Where the user corrected or steered you 39: - What tools and permissions were needed 40: - What agents were used 41: - What the goals and success artifacts were 42: ### Step 2: Interview the User 43: You will use the AskUserQuestion to understand what the user wants to automate. Important notes: 44: - Use AskUserQuestion for ALL questions! Never ask questions via plain text. 45: - For each round, iterate as much as needed until the user is happy. 46: - The user always has a freeform "Other" option to type edits or feedback -- do NOT add your own "Needs tweaking" or "I'll provide edits" option. Just offer the substantive choices. 47: **Round 1: High level confirmation** 48: - Suggest a name and description for the skill based on your analysis. Ask the user to confirm or rename. 49: - Suggest high-level goal(s) and specific success criteria for the skill. 50: **Round 2: More details** 51: - Present the high-level steps you identified as a numbered list. Tell the user you will dig into the detail in the next round. 52: - If you think the skill will require arguments, suggest arguments based on what you observed. Make sure you understand what someone would need to provide. 53: - If it's not clear, ask if this skill should run inline (in the current conversation) or forked (as a sub-agent with its own context). Forked is better for self-contained tasks that don't need mid-process user input; inline is better when the user wants to steer mid-process. 54: - Ask where the skill should be saved. Suggest a default based on context (repo-specific workflows → repo, cross-repo personal workflows → user). Options: 55: - **This repo** (\`.claude/skills/<name>/SKILL.md\`) — for workflows specific to this project 56: - **Personal** (\`~/.claude/skills/<name>/SKILL.md\`) — follows you across all repos 57: **Round 3: Breaking down each step** 58: For each major step, if it's not glaringly obvious, ask: 59: - What does this step produce that later steps need? (data, artifacts, IDs) 60: - What proves that this step succeeded, and that we can move on? 61: - Should the user be asked to confirm before proceeding? (especially for irreversible actions like merging, sending messages, or destructive operations) 62: - Are any steps independent and could run in parallel? (e.g., posting to Slack and monitoring CI at the same time) 63: - How should the skill be executed? (e.g. always use a Task agent to conduct code review, or invoke an agent team for a set of concurrent steps) 64: - What are the hard constraints or hard preferences? Things that must or must not happen? 65: You may do multiple rounds of AskUserQuestion here, one round per step, especially if there are more than 3 steps or many clarification questions. Iterate as much as needed. 66: IMPORTANT: Pay special attention to places where the user corrected you during the session, to help inform your design. 67: **Round 4: Final questions** 68: - Confirm when this skill should be invoked, and suggest/confirm trigger phrases too. (e.g. For a cherrypick workflow you could say: Use when the user wants to cherry-pick a PR to a release branch. Examples: 'cherry-pick to release', 'CP this PR', 'hotfix.') 69: - You can also ask for any other gotchas or things to watch out for, if it's still unclear. 70: Stop interviewing once you have enough information. IMPORTANT: Don't over-ask for simple processes! 71: ### Step 3: Write the SKILL.md 72: Create the skill directory and file at the location the user chose in Round 2. 73: Use this format: 74: \`\`\`markdown 75: --- 76: name: {{skill-name}} 77: description: {{one-line description}} 78: allowed-tools: 79: {{list of tool permission patterns observed during session}} 80: when_to_use: {{detailed description of when Claude should automatically invoke this skill, including trigger phrases and example user messages}} 81: argument-hint: "{{hint showing argument placeholders}}" 82: arguments: 83: {{list of argument names}} 84: context: {{inline or fork -- omit for inline}} 85: --- 86: # {{Skill Title}} 87: Description of skill 88: ## Inputs 89: - \`$arg_name\`: Description of this input 90: ## Goal 91: Clearly stated goal for this workflow. Best if you have clearly defined artifacts or criteria for completion. 92: ## Steps 93: ### 1. Step Name 94: What to do in this step. Be specific and actionable. Include commands when appropriate. 95: **Success criteria**: ALWAYS include this! This shows that the step is done and we can move on. Can be a list. 96: IMPORTANT: see the next section below for the per-step annotations you can optionally include for each step. 97: ... 98: \`\`\` 99: **Per-step annotations**: 100: - **Success criteria** is REQUIRED on every step. This helps the model understand what the user expects from their workflow, and when it should have the confidence to move on. 101: - **Execution**: \`Direct\` (default), \`Task agent\` (straightforward subagents), \`Teammate\` (agent with true parallelism and inter-agent communication), or \`[human]\` (user does it). Only needs specifying if not Direct. 102: - **Artifacts**: Data this step produces that later steps need (e.g., PR number, commit SHA). Only include if later steps depend on it. 103: - **Human checkpoint**: When to pause and ask the user before proceeding. Include for irreversible actions (merging, sending messages), error judgment (merge conflicts), or output review. 104: - **Rules**: Hard rules for the workflow. User corrections during the reference session can be especially useful here. 105: **Step structure tips:** 106: - Steps that can run concurrently use sub-numbers: 3a, 3b 107: - Steps requiring the user to act get \`[human]\` in the title 108: - Keep simple skills simple -- a 2-step skill doesn't need annotations on every step 109: **Frontmatter rules:** 110: - \`allowed-tools\`: Minimum permissions needed (use patterns like \`Bash(gh:*)\` not \`Bash\`) 111: - \`context\`: Only set \`context: fork\` for self-contained skills that don't need mid-process user input. 112: - \`when_to_use\` is CRITICAL -- tells the model when to auto-invoke. Start with "Use when..." and include trigger phrases. Example: "Use when the user wants to cherry-pick a PR to a release branch. Examples: 'cherry-pick to release', 'CP this PR', 'hotfix'." 113: - \`arguments\` and \`argument-hint\`: Only include if the skill takes parameters. Use \`$name\` in the body for substitution. 114: ### Step 4: Confirm and Save 115: Before writing the file, output the complete SKILL.md content as a yaml code block in your response so the user can review it with proper syntax highlighting. Then ask for confirmation using AskUserQuestion with a simple question like "Does this SKILL.md look good to save?" — do NOT use the body field, keep the question concise. 116: After writing, tell the user: 117: - Where the skill was saved 118: - How to invoke it: \`/{{skill-name}} [arguments]\` 119: - That they can edit the SKILL.md directly to refine it 120: ` 121: export function registerSkillifySkill(): void { 122: if (process.env.USER_TYPE !== 'ant') { 123: return 124: } 125: registerBundledSkill({ 126: name: 'skillify', 127: description: 128: "Capture this session's repeatable process into a skill. Call at end of the process you want to capture with an optional description.", 129: allowedTools: [ 130: 'Read', 131: 'Write', 132: 'Edit', 133: 'Glob', 134: 'Grep', 135: 'AskUserQuestion', 136: 'Bash(mkdir:*)', 137: ], 138: userInvocable: true, 139: disableModelInvocation: true, 140: argumentHint: '[description of the process you want to capture]', 141: async getPromptForCommand(args, context) { 142: const sessionMemory = 143: (await getSessionMemoryContent()) ?? 'No session memory available.' 144: const userMessages = extractUserMessages( 145: getMessagesAfterCompactBoundary(context.messages), 146: ) 147: const userDescriptionBlock = args 148: ? `The user described this process as: "${args}"` 149: : '' 150: const prompt = SKILLIFY_PROMPT.replace('{{sessionMemory}}', sessionMemory) 151: .replace('{{userMessages}}', userMessages.join('\n\n---\n\n')) 152: .replace('{{userDescriptionBlock}}', userDescriptionBlock) 153: return [{ type: 'text', text: prompt }] 154: }, 155: }) 156: }

File: src/skills/bundled/stuck.ts

typescript 1: import { registerBundledSkill } from '../bundledSkills.js' 2: const STUCK_PROMPT = `# /stuck — diagnose frozen/slow Claude Code sessions 3: The user thinks another Claude Code session on this machine is frozen, stuck, or very slow. Investigate and post a report to #claude-code-feedback. 4: ## What to look for 5: Scan for other Claude Code processes (excluding the current one — PID is in \`process.pid\` but for shell commands just exclude the PID you see running this prompt). Process names are typically \`claude\` (installed) or \`cli\` (native dev build). 6: Signs of a stuck session: 7: - **High CPU (≥90%) sustained** — likely an infinite loop. Sample twice, 1-2s apart, to confirm it's not a transient spike. 8: - **Process state \`D\` (uninterruptible sleep)** — often an I/O hang. The \`state\` column in \`ps\` output; first character matters (ignore modifiers like \`+\`, \`s\`, \`<\`). 9: - **Process state \`T\` (stopped)** — user probably hit Ctrl+Z by accident. 10: - **Process state \`Z\` (zombie)** — parent isn't reaping. 11: - **Very high RSS (≥4GB)** — possible memory leak making the session sluggish. 12: - **Stuck child process** — a hung \`git\`, \`node\`, or shell subprocess can freeze the parent. Check \`pgrep -lP <pid>\` for each session. 13: ## Investigation steps 14: 1. **List all Claude Code processes** (macOS/Linux): 15: \`\`\` 16: ps -axo pid=,pcpu=,rss=,etime=,state=,comm=,command= | grep -E '(claude|cli)' | grep -v grep 17: \`\`\` 18: Filter to rows where \`comm\` is \`claude\` or (\`cli\` AND the command path contains "claude"). 19: 2. **For anything suspicious**, gather more context: 20: - Child processes: \`pgrep -lP <pid>\` 21: - If high CPU: sample again after 1-2s to confirm it's sustained 22: - If a child looks hung (e.g., a git command), note its full command line with \`ps -p <child_pid> -o command=\` 23: - Check the session's debug log if you can infer the session ID: \`~/.claude/debug/<session-id>.txt\` (the last few hundred lines often show what it was doing before hanging) 24: 3. **Consider a stack dump** for a truly frozen process (advanced, optional): 25: - macOS: \`sample <pid> 3\` gives a 3-second native stack sample 26: - This is big — only grab it if the process is clearly hung and you want to know *why* 27: ## Report 28: **Only post to Slack if you actually found something stuck.** If every session looks healthy, tell the user that directly — do not post an all-clear to the channel. 29: If you did find a stuck/slow session, post to **#claude-code-feedback** (channel ID: \`C07VBSHV7EV\`) using the Slack MCP tool. Use ToolSearch to find \`slack_send_message\` if it's not already loaded. 30: **Use a two-message structure** to keep the channel scannable: 31: 1. **Top-level message** — one short line: hostname, Claude Code version, and a terse symptom (e.g. "session PID 12345 pegged at 100% CPU for 10min" or "git subprocess hung in D state"). No code blocks, no details. 32: 2. **Thread reply** — the full diagnostic dump. Pass the top-level message's \`ts\` as \`thread_ts\`. Include: 33: - PID, CPU%, RSS, state, uptime, command line, child processes 34: - Your diagnosis of what's likely wrong 35: - Relevant debug log tail or \`sample\` output if you captured it 36: If Slack MCP isn't available, format the report as a message the user can copy-paste into #claude-code-feedback (and let them know to thread the details themselves). 37: ## Notes 38: - Don't kill or signal any processes — this is diagnostic only. 39: - If the user gave an argument (e.g., a specific PID or symptom), focus there first. 40: ` 41: export function registerStuckSkill(): void { 42: if (process.env.USER_TYPE !== 'ant') { 43: return 44: } 45: registerBundledSkill({ 46: name: 'stuck', 47: description: 48: '[ANT-ONLY] Investigate frozen/stuck/slow Claude Code sessions on this machine and post a diagnostic report to #claude-code-feedback.', 49: userInvocable: true, 50: async getPromptForCommand(args) { 51: let prompt = STUCK_PROMPT 52: if (args) { 53: prompt += `\n## User-provided context\n\n${args}\n` 54: } 55: return [{ type: 'text', text: prompt }] 56: }, 57: }) 58: }

File: src/skills/bundled/updateConfig.ts

typescript 1: import { toJSONSchema } from 'zod/v4' 2: import { SettingsSchema } from '../../utils/settings/types.js' 3: import { jsonStringify } from '../../utils/slowOperations.js' 4: import { registerBundledSkill } from '../bundledSkills.js' 5: function generateSettingsSchema(): string { 6: const jsonSchema = toJSONSchema(SettingsSchema(), { io: 'input' }) 7: return jsonStringify(jsonSchema, null, 2) 8: } 9: const SETTINGS_EXAMPLES_DOCS = `## Settings File Locations 10: Choose the appropriate file based on scope: 11: | File | Scope | Git | Use For | 12: |------|-------|-----|---------| 13: | \`~/.claude/settings.json\` | Global | N/A | Personal preferences for all projects | 14: | \`.claude/settings.json\` | Project | Commit | Team-wide hooks, permissions, plugins | 15: | \`.claude/settings.local.json\` | Project | Gitignore | Personal overrides for this project | 16: Settings load in order: user → project → local (later overrides earlier). 17: ## Settings Schema Reference 18: ### Permissions 19: \`\`\`json 20: { 21: "permissions": { 22: "allow": ["Bash(npm:*)", "Edit(.claude)", "Read"], 23: "deny": ["Bash(rm -rf:*)"], 24: "ask": ["Write(/etc/*)"], 25: "defaultMode": "default" | "plan" | "acceptEdits" | "dontAsk", 26: "additionalDirectories": ["/extra/dir"] 27: } 28: } 29: \`\`\` 30: **Permission Rule Syntax:** 31: - Exact match: \`"Bash(npm run test)"\` 32: - Prefix wildcard: \`"Bash(git:*)"\` - matches \`git status\`, \`git commit\`, etc. 33: - Tool only: \`"Read"\` - allows all Read operations 34: ### Environment Variables 35: \`\`\`json 36: { 37: "env": { 38: "DEBUG": "true", 39: "MY_API_KEY": "value" 40: } 41: } 42: \`\`\` 43: ### Model & Agent 44: \`\`\`json 45: { 46: "model": "sonnet", // or "opus", "haiku", full model ID 47: "agent": "agent-name", 48: "alwaysThinkingEnabled": true 49: } 50: \`\`\` 51: ### Attribution (Commits & PRs) 52: \`\`\`json 53: { 54: "attribution": { 55: "commit": "Custom commit trailer text", 56: "pr": "Custom PR description text" 57: } 58: } 59: \`\`\` 60: Set \`commit\` or \`pr\` to empty string \`""\` to hide that attribution. 61: ### MCP Server Management 62: \`\`\`json 63: { 64: "enableAllProjectMcpServers": true, 65: "enabledMcpjsonServers": ["server1", "server2"], 66: "disabledMcpjsonServers": ["blocked-server"] 67: } 68: \`\`\` 69: ### Plugins 70: \`\`\`json 71: { 72: "enabledPlugins": { 73: "formatter@anthropic-tools": true 74: } 75: } 76: \`\`\` 77: Plugin syntax: \`plugin-name@source\` where source is \`claude-code-marketplace\`, \`claude-plugins-official\`, or \`builtin\`. 78: ### Other Settings 79: - \`language\`: Preferred response language (e.g., "japanese") 80: - \`cleanupPeriodDays\`: Days to keep transcripts (default: 30; 0 disables persistence entirely) 81: - \`respectGitignore\`: Whether to respect .gitignore (default: true) 82: - \`spinnerTipsEnabled\`: Show tips in spinner 83: - \`spinnerVerbs\`: Customize spinner verbs (\`{ "mode": "append" | "replace", "verbs": [...] }\`) 84: - \`spinnerTipsOverride\`: Override spinner tips (\`{ "excludeDefault": true, "tips": ["Custom tip"] }\`) 85: - \`syntaxHighlightingDisabled\`: Disable diff highlighting 86: ` 87: const HOOKS_DOCS = `## Hooks Configuration 88: Hooks run commands at specific points in Claude Code's lifecycle. 89: ### Hook Structure 90: \`\`\`json 91: { 92: "hooks": { 93: "EVENT_NAME": [ 94: { 95: "matcher": "ToolName|OtherTool", 96: "hooks": [ 97: { 98: "type": "command", 99: "command": "your-command-here", 100: "timeout": 60, 101: "statusMessage": "Running..." 102: } 103: ] 104: } 105: ] 106: } 107: } 108: \`\`\` 109: ### Hook Events 110: | Event | Matcher | Purpose | 111: |-------|---------|---------| 112: | PermissionRequest | Tool name | Run before permission prompt | 113: | PreToolUse | Tool name | Run before tool, can block | 114: | PostToolUse | Tool name | Run after successful tool | 115: | PostToolUseFailure | Tool name | Run after tool fails | 116: | Notification | Notification type | Run on notifications | 117: | Stop | - | Run when Claude stops (including clear, resume, compact) | 118: | PreCompact | "manual"/"auto" | Before compaction | 119: | PostCompact | "manual"/"auto" | After compaction (receives summary) | 120: | UserPromptSubmit | - | When user submits | 121: | SessionStart | - | When session starts | 122: **Common tool matchers:** \`Bash\`, \`Write\`, \`Edit\`, \`Read\`, \`Glob\`, \`Grep\` 123: ### Hook Types 124: **1. Command Hook** - Runs a shell command: 125: \`\`\`json 126: { "type": "command", "command": "prettier --write $FILE", "timeout": 30 } 127: \`\`\` 128: **2. Prompt Hook** - Evaluates a condition with LLM: 129: \`\`\`json 130: { "type": "prompt", "prompt": "Is this safe? $ARGUMENTS" } 131: \`\`\` 132: Only available for tool events: PreToolUse, PostToolUse, PermissionRequest. 133: **3. Agent Hook** - Runs an agent with tools: 134: \`\`\`json 135: { "type": "agent", "prompt": "Verify tests pass: $ARGUMENTS" } 136: \`\`\` 137: Only available for tool events: PreToolUse, PostToolUse, PermissionRequest. 138: ### Hook Input (stdin JSON) 139: \`\`\`json 140: { 141: "session_id": "abc123", 142: "tool_name": "Write", 143: "tool_input": { "file_path": "/path/to/file.txt", "content": "..." }, 144: "tool_response": { "success": true } // PostToolUse only 145: } 146: \`\`\` 147: ### Hook JSON Output 148: Hooks can return JSON to control behavior: 149: \`\`\`json 150: { 151: "systemMessage": "Warning shown to user in UI", 152: "continue": false, 153: "stopReason": "Message shown when blocking", 154: "suppressOutput": false, 155: "decision": "block", 156: "reason": "Explanation for decision", 157: "hookSpecificOutput": { 158: "hookEventName": "PostToolUse", 159: "additionalContext": "Context injected back to model" 160: } 161: } 162: \`\`\` 163: **Fields:** 164: - \`systemMessage\` - Display a message to the user (all hooks) 165: - \`continue\` - Set to \`false\` to block/stop (default: true) 166: - \`stopReason\` - Message shown when \`continue\` is false 167: - \`suppressOutput\` - Hide stdout from transcript (default: false) 168: - \`decision\` - "block" for PostToolUse/Stop/UserPromptSubmit hooks (deprecated for PreToolUse, use hookSpecificOutput.permissionDecision instead) 169: - \`reason\` - Explanation for decision 170: - \`hookSpecificOutput\` - Event-specific output (must include \`hookEventName\`): 171: - \`additionalContext\` - Text injected into model context 172: - \`permissionDecision\` - "allow", "deny", or "ask" (PreToolUse only) 173: - \`permissionDecisionReason\` - Reason for the permission decision (PreToolUse only) 174: - \`updatedInput\` - Modified tool input (PreToolUse only) 175: ### Common Patterns 176: **Auto-format after writes:** 177: \`\`\`json 178: { 179: "hooks": { 180: "PostToolUse": [{ 181: "matcher": "Write|Edit", 182: "hooks": [{ 183: "type": "command", 184: "command": "jq -r '.tool_response.filePath // .tool_input.file_path' | { read -r f; prettier --write \\"$f\\"; } 2>/dev/null || true" 185: }] 186: }] 187: } 188: } 189: \`\`\` 190: **Log all bash commands:** 191: \`\`\`json 192: { 193: "hooks": { 194: "PreToolUse": [{ 195: "matcher": "Bash", 196: "hooks": [{ 197: "type": "command", 198: "command": "jq -r '.tool_input.command' >> ~/.claude/bash-log.txt" 199: }] 200: }] 201: } 202: } 203: \`\`\` 204: **Stop hook that displays message to user:** 205: Command must output JSON with \`systemMessage\` field: 206: \`\`\`bash 207: # Example command that outputs: {"systemMessage": "Session complete!"} 208: echo '{"systemMessage": "Session complete!"}' 209: \`\`\` 210: **Run tests after code changes:** 211: \`\`\`json 212: { 213: "hooks": { 214: "PostToolUse": [{ 215: "matcher": "Write|Edit", 216: "hooks": [{ 217: "type": "command", 218: "command": "jq -r '.tool_input.file_path // .tool_response.filePath' | grep -E '\\\\.(ts|js)$' && npm test || true" 219: }] 220: }] 221: } 222: } 223: \`\`\` 224: ` 225: const HOOK_VERIFICATION_FLOW = `## Constructing a Hook (with verification) 226: Given an event, matcher, target file, and desired behavior, follow this flow. Each step catches a different failure class — a hook that silently does nothing is worse than no hook. 227: 1. **Dedup check.** Read the target file. If a hook already exists on the same event+matcher, show the existing command and ask: keep it, replace it, or add alongside. 228: 2. **Construct the command for THIS project — don't assume.** The hook receives JSON on stdin. Build a command that: 229: - Extracts any needed payload safely — use \`jq -r\` into a quoted variable or \`{ read -r f; ... "$f"; }\`, NOT unquoted \`| xargs\` (splits on spaces) 230: - Invokes the underlying tool the way this project runs it (npx/bunx/yarn/pnpm? Makefile target? globally-installed?) 231: - Skips inputs the tool doesn't handle (formatters often have \`--ignore-unknown\`; if not, guard by extension) 232: - Stays RAW for now — no \`|| true\`, no stderr suppression. You'll wrap it after the pipe-test passes. 233: 3. **Pipe-test the raw command.** Synthesize the stdin payload the hook will receive and pipe it directly: 234: - \`Pre|PostToolUse\` on \`Write|Edit\`: \`echo '{"tool_name":"Edit","tool_input":{"file_path":"<a real file from this repo>"}}' | <cmd>\` 235: - \`Pre|PostToolUse\` on \`Bash\`: \`echo '{"tool_name":"Bash","tool_input":{"command":"ls"}}' | <cmd>\` 236: - \`Stop\`/\`UserPromptSubmit\`/\`SessionStart\`: most commands don't read stdin, so \`echo '{}' | <cmd>\` suffices 237: Check exit code AND side effect (file actually formatted, test actually ran). If it fails you get a real error — fix (wrong package manager? tool not installed? jq path wrong?) and retest. Once it works, wrap with \`2>/dev/null || true\` (unless the user wants a blocking check). 238: 4. **Write the JSON.** Merge into the target file (schema shape in the "Hook Structure" section above). If this creates \`.claude/settings.local.json\` for the first time, add it to .gitignore — the Write tool doesn't auto-gitignore it. 239: 5. **Validate syntax + schema in one shot:** 240: \`jq -e '.hooks.<event>[] | select(.matcher == "<matcher>") | .hooks[] | select(.type == "command") | .command' <target-file>\` 241: Exit 0 + prints your command = correct. Exit 4 = matcher doesn't match. Exit 5 = malformed JSON or wrong nesting. A broken settings.json silently disables ALL settings from that file — fix any pre-existing malformation too. 242: 6. **Prove the hook fires** — only for \`Pre|PostToolUse\` on a matcher you can trigger in-turn (\`Write|Edit\` via Edit, \`Bash\` via Bash). \`Stop\`/\`UserPromptSubmit\`/\`SessionStart\` fire outside this turn — skip to step 7. 243: For a **formatter** on \`PostToolUse\`/\`Write|Edit\`: introduce a detectable violation via Edit (two consecutive blank lines, bad indentation, missing semicolon — something this formatter corrects; NOT trailing whitespace, Edit strips that before writing), re-read, confirm the hook **fixed** it. For **anything else**: temporarily prefix the command in settings.json with \`echo "$(date) hook fired" >> /tmp/claude-hook-check.txt; \`, trigger the matching tool (Edit for \`Write|Edit\`, a harmless \`true\` for \`Bash\`), read the sentinel file. 244: **Always clean up** — revert the violation, strip the sentinel prefix — whether the proof passed or failed. 245: **If proof fails but pipe-test passed and \`jq -e\` passed**: the settings watcher isn't watching \`.claude/\` — it only watches directories that had a settings file when this session started. The hook is written correctly. Tell the user to open \`/hooks\` once (reloads config) or restart — you can't do this yourself; \`/hooks\` is a user UI menu and opening it ends this turn. 246: 7. **Handoff.** Tell the user the hook is live (or needs \`/hooks\`/restart per the watcher caveat). Point them at \`/hooks\` to review, edit, or disable it later. The UI only shows "Ran N hooks" if a hook errors or is slow — silent success is invisible by design. 247: ` 248: const UPDATE_CONFIG_PROMPT = `# Update Config Skill 249: Modify Claude Code configuration by updating settings.json files. 250: ## When Hooks Are Required (Not Memory) 251: If the user wants something to happen automatically in response to an EVENT, they need a **hook** configured in settings.json. Memory/preferences cannot trigger automated actions. 252: **These require hooks:** 253: - "Before compacting, ask me what to preserve" → PreCompact hook 254: - "After writing files, run prettier" → PostToolUse hook with Write|Edit matcher 255: - "When I run bash commands, log them" → PreToolUse hook with Bash matcher 256: - "Always run tests after code changes" → PostToolUse hook 257: **Hook events:** PreToolUse, PostToolUse, PreCompact, PostCompact, Stop, Notification, SessionStart 258: ## CRITICAL: Read Before Write 259: **Always read the existing settings file before making changes.** Merge new settings with existing ones - never replace the entire file. 260: ## CRITICAL: Use AskUserQuestion for Ambiguity 261: When the user's request is ambiguous, use AskUserQuestion to clarify: 262: - Which settings file to modify (user/project/local) 263: - Whether to add to existing arrays or replace them 264: - Specific values when multiple options exist 265: ## Decision: Config Tool vs Direct Edit 266: **Use the Config tool** for these simple settings: 267: - \`theme\`, \`editorMode\`, \`verbose\`, \`model\` 268: - \`language\`, \`alwaysThinkingEnabled\` 269: - \`permissions.defaultMode\` 270: **Edit settings.json directly** for: 271: - Hooks (PreToolUse, PostToolUse, etc.) 272: - Complex permission rules (allow/deny arrays) 273: - Environment variables 274: - MCP server configuration 275: - Plugin configuration 276: ## Workflow 277: 1. **Clarify intent** - Ask if the request is ambiguous 278: 2. **Read existing file** - Use Read tool on the target settings file 279: 3. **Merge carefully** - Preserve existing settings, especially arrays 280: 4. **Edit file** - Use Edit tool (if file doesn't exist, ask user to create it first) 281: 5. **Confirm** - Tell user what was changed 282: ## Merging Arrays (Important!) 283: When adding to permission arrays or hook arrays, **merge with existing**, don't replace: 284: **WRONG** (replaces existing permissions): 285: \`\`\`json 286: { "permissions": { "allow": ["Bash(npm:*)"] } } 287: \`\`\` 288: **RIGHT** (preserves existing + adds new): 289: \`\`\`json 290: { 291: "permissions": { 292: "allow": [ 293: "Bash(git:*)", // existing 294: "Edit(.claude)", // existing 295: "Bash(npm:*)" // new 296: ] 297: } 298: } 299: \`\`\` 300: ${SETTINGS_EXAMPLES_DOCS} 301: ${HOOKS_DOCS} 302: ${HOOK_VERIFICATION_FLOW} 303: ## Example Workflows 304: ### Adding a Hook 305: User: "Format my code after Claude writes it" 306: 1. **Clarify**: Which formatter? (prettier, gofmt, etc.) 307: 2. **Read**: \`.claude/settings.json\` (or create if missing) 308: 3. **Merge**: Add to existing hooks, don't replace 309: 4. **Result**: 310: \`\`\`json 311: { 312: "hooks": { 313: "PostToolUse": [{ 314: "matcher": "Write|Edit", 315: "hooks": [{ 316: "type": "command", 317: "command": "jq -r '.tool_response.filePath // .tool_input.file_path' | { read -r f; prettier --write \\"$f\\"; } 2>/dev/null || true" 318: }] 319: }] 320: } 321: } 322: \`\`\` 323: ### Adding Permissions 324: User: "Allow npm commands without prompting" 325: 1. **Read**: Existing permissions 326: 2. **Merge**: Add \`Bash(npm:*)\` to allow array 327: 3. **Result**: Combined with existing allows 328: ### Environment Variables 329: User: "Set DEBUG=true" 330: 1. **Decide**: User settings (global) or project settings? 331: 2. **Read**: Target file 332: 3. **Merge**: Add to env object 333: \`\`\`json 334: { "env": { "DEBUG": "true" } } 335: \`\`\` 336: ## Common Mistakes to Avoid 337: 1. **Replacing instead of merging** - Always preserve existing settings 338: 2. **Wrong file** - Ask user if scope is unclear 339: 3. **Invalid JSON** - Validate syntax after changes 340: 4. **Forgetting to read first** - Always read before write 341: ## Troubleshooting Hooks 342: If a hook isn't running: 343: 1. **Check the settings file** - Read ~/.claude/settings.json or .claude/settings.json 344: 2. **Verify JSON syntax** - Invalid JSON silently fails 345: 3. **Check the matcher** - Does it match the tool name? (e.g., "Bash", "Write", "Edit") 346: 4. **Check hook type** - Is it "command", "prompt", or "agent"? 347: 5. **Test the command** - Run the hook command manually to see if it works 348: 6. **Use --debug** - Run \`claude --debug\` to see hook execution logs 349: ` 350: export function registerUpdateConfigSkill(): void { 351: registerBundledSkill({ 352: name: 'update-config', 353: description: 354: 'Use this skill to configure the Claude Code harness via settings.json. Automated behaviors ("from now on when X", "each time X", "whenever X", "before/after X") require hooks configured in settings.json - the harness executes these, not Claude, so memory/preferences cannot fulfill them. Also use for: permissions ("allow X", "add permission", "move permission to"), env vars ("set X=Y"), hook troubleshooting, or any changes to settings.json/settings.local.json files. Examples: "allow npm commands", "add bq permission to global settings", "move permission to user settings", "set DEBUG=true", "when claude stops show X". For simple settings like theme/model, use Config tool.', 355: allowedTools: ['Read'], 356: userInvocable: true, 357: async getPromptForCommand(args) { 358: if (args.startsWith('[hooks-only]')) { 359: const req = args.slice('[hooks-only]'.length).trim() 360: let prompt = HOOKS_DOCS + '\n\n' + HOOK_VERIFICATION_FLOW 361: if (req) { 362: prompt += `\n\n## Task\n\n${req}` 363: } 364: return [{ type: 'text', text: prompt }] 365: } 366: const jsonSchema = generateSettingsSchema() 367: let prompt = UPDATE_CONFIG_PROMPT 368: prompt += `\n\n## Full Settings JSON Schema\n\n\`\`\`json\n${jsonSchema}\n\`\`\`` 369: if (args) { 370: prompt += `\n\n## User Request\n\n${args}` 371: } 372: return [{ type: 'text', text: prompt }] 373: }, 374: }) 375: }

File: src/skills/bundled/verify.ts

typescript 1: import { parseFrontmatter } from '../../utils/frontmatterParser.js' 2: import { registerBundledSkill } from '../bundledSkills.js' 3: import { SKILL_FILES, SKILL_MD } from './verifyContent.js' 4: const { frontmatter, content: SKILL_BODY } = parseFrontmatter(SKILL_MD) 5: const DESCRIPTION = 6: typeof frontmatter.description === 'string' 7: ? frontmatter.description 8: : 'Verify a code change does what it should by running the app.' 9: export function registerVerifySkill(): void { 10: if (process.env.USER_TYPE !== 'ant') { 11: return 12: } 13: registerBundledSkill({ 14: name: 'verify', 15: description: DESCRIPTION, 16: userInvocable: true, 17: files: SKILL_FILES, 18: async getPromptForCommand(args) { 19: const parts: string[] = [SKILL_BODY.trimStart()] 20: if (args) { 21: parts.push(`## User Request\n\n${args}`) 22: } 23: return [{ type: 'text', text: parts.join('\n\n') }] 24: }, 25: }) 26: }

File: src/skills/bundled/verifyContent.ts

typescript 1: import cliMd from './verify/examples/cli.md' 2: import serverMd from './verify/examples/server.md' 3: import skillMd from './verify/SKILL.md' 4: export const SKILL_MD: string = skillMd 5: export const SKILL_FILES: Record<string, string> = { 6: 'examples/cli.md': cliMd, 7: 'examples/server.md': serverMd, 8: }

File: src/skills/bundledSkills.ts

typescript 1: import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/index.mjs' 2: import { constants as fsConstants } from 'fs' 3: import { mkdir, open } from 'fs/promises' 4: import { dirname, isAbsolute, join, normalize, sep as pathSep } from 'path' 5: import type { ToolUseContext } from '../Tool.js' 6: import type { Command } from '../types/command.js' 7: import { logForDebugging } from '../utils/debug.js' 8: import { getBundledSkillsRoot } from '../utils/permissions/filesystem.js' 9: import type { HooksSettings } from '../utils/settings/types.js' 10: export type BundledSkillDefinition = { 11: name: string 12: description: string 13: aliases?: string[] 14: whenToUse?: string 15: argumentHint?: string 16: allowedTools?: string[] 17: model?: string 18: disableModelInvocation?: boolean 19: userInvocable?: boolean 20: isEnabled?: () => boolean 21: hooks?: HooksSettings 22: context?: 'inline' | 'fork' 23: agent?: string 24: files?: Record<string, string> 25: getPromptForCommand: ( 26: args: string, 27: context: ToolUseContext, 28: ) => Promise<ContentBlockParam[]> 29: } 30: const bundledSkills: Command[] = [] 31: export function registerBundledSkill(definition: BundledSkillDefinition): void { 32: const { files } = definition 33: let skillRoot: string | undefined 34: let getPromptForCommand = definition.getPromptForCommand 35: if (files && Object.keys(files).length > 0) { 36: skillRoot = getBundledSkillExtractDir(definition.name) 37: let extractionPromise: Promise<string | null> | undefined 38: const inner = definition.getPromptForCommand 39: getPromptForCommand = async (args, ctx) => { 40: extractionPromise ??= extractBundledSkillFiles(definition.name, files) 41: const extractedDir = await extractionPromise 42: const blocks = await inner(args, ctx) 43: if (extractedDir === null) return blocks 44: return prependBaseDir(blocks, extractedDir) 45: } 46: } 47: const command: Command = { 48: type: 'prompt', 49: name: definition.name, 50: description: definition.description, 51: aliases: definition.aliases, 52: hasUserSpecifiedDescription: true, 53: allowedTools: definition.allowedTools ?? [], 54: argumentHint: definition.argumentHint, 55: whenToUse: definition.whenToUse, 56: model: definition.model, 57: disableModelInvocation: definition.disableModelInvocation ?? false, 58: userInvocable: definition.userInvocable ?? true, 59: contentLength: 0, 60: source: 'bundled', 61: loadedFrom: 'bundled', 62: hooks: definition.hooks, 63: skillRoot, 64: context: definition.context, 65: agent: definition.agent, 66: isEnabled: definition.isEnabled, 67: isHidden: !(definition.userInvocable ?? true), 68: progressMessage: 'running', 69: getPromptForCommand, 70: } 71: bundledSkills.push(command) 72: } 73: export function getBundledSkills(): Command[] { 74: return [...bundledSkills] 75: } 76: export function clearBundledSkills(): void { 77: bundledSkills.length = 0 78: } 79: export function getBundledSkillExtractDir(skillName: string): string { 80: return join(getBundledSkillsRoot(), skillName) 81: } 82: async function extractBundledSkillFiles( 83: skillName: string, 84: files: Record<string, string>, 85: ): Promise<string | null> { 86: const dir = getBundledSkillExtractDir(skillName) 87: try { 88: await writeSkillFiles(dir, files) 89: return dir 90: } catch (e) { 91: logForDebugging( 92: `Failed to extract bundled skill '${skillName}' to ${dir}: ${e instanceof Error ? e.message : String(e)}`, 93: ) 94: return null 95: } 96: } 97: async function writeSkillFiles( 98: dir: string, 99: files: Record<string, string>, 100: ): Promise<void> { 101: const byParent = new Map<string, [string, string][]>() 102: for (const [relPath, content] of Object.entries(files)) { 103: const target = resolveSkillFilePath(dir, relPath) 104: const parent = dirname(target) 105: const entry: [string, string] = [target, content] 106: const group = byParent.get(parent) 107: if (group) group.push(entry) 108: else byParent.set(parent, [entry]) 109: } 110: await Promise.all( 111: [...byParent].map(async ([parent, entries]) => { 112: await mkdir(parent, { recursive: true, mode: 0o700 }) 113: await Promise.all(entries.map(([p, c]) => safeWriteFile(p, c))) 114: }), 115: ) 116: } 117: const O_NOFOLLOW = fsConstants.O_NOFOLLOW ?? 0 118: const SAFE_WRITE_FLAGS = 119: process.platform === 'win32' 120: ? 'wx' 121: : fsConstants.O_WRONLY | 122: fsConstants.O_CREAT | 123: fsConstants.O_EXCL | 124: O_NOFOLLOW 125: async function safeWriteFile(p: string, content: string): Promise<void> { 126: const fh = await open(p, SAFE_WRITE_FLAGS, 0o600) 127: try { 128: await fh.writeFile(content, 'utf8') 129: } finally { 130: await fh.close() 131: } 132: } 133: function resolveSkillFilePath(baseDir: string, relPath: string): string { 134: const normalized = normalize(relPath) 135: if ( 136: isAbsolute(normalized) || 137: normalized.split(pathSep).includes('..') || 138: normalized.split('/').includes('..') 139: ) { 140: throw new Error(`bundled skill file path escapes skill dir: ${relPath}`) 141: } 142: return join(baseDir, normalized) 143: } 144: function prependBaseDir( 145: blocks: ContentBlockParam[], 146: baseDir: string, 147: ): ContentBlockParam[] { 148: const prefix = `Base directory for this skill: ${baseDir}\n\n` 149: if (blocks.length > 0 && blocks[0]!.type === 'text') { 150: return [ 151: { type: 'text', text: prefix + blocks[0]!.text }, 152: ...blocks.slice(1), 153: ] 154: } 155: return [{ type: 'text', text: prefix }, ...blocks] 156: }

File: src/skills/loadSkillsDir.ts

typescript 1: import { realpath } from 'fs/promises' 2: import ignore from 'ignore' 3: import memoize from 'lodash-es/memoize.js' 4: import { 5: basename, 6: dirname, 7: isAbsolute, 8: join, 9: sep as pathSep, 10: relative, 11: } from 'path' 12: import { 13: getAdditionalDirectoriesForClaudeMd, 14: getSessionId, 15: } from '../bootstrap/state.js' 16: import { 17: type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 18: logEvent, 19: } from '../services/analytics/index.js' 20: import { roughTokenCountEstimation } from '../services/tokenEstimation.js' 21: import type { Command, PromptCommand } from '../types/command.js' 22: import { 23: parseArgumentNames, 24: substituteArguments, 25: } from '../utils/argumentSubstitution.js' 26: import { logForDebugging } from '../utils/debug.js' 27: import { 28: EFFORT_LEVELS, 29: type EffortValue, 30: parseEffortValue, 31: } from '../utils/effort.js' 32: import { 33: getClaudeConfigHomeDir, 34: isBareMode, 35: isEnvTruthy, 36: } from '../utils/envUtils.js' 37: import { isENOENT, isFsInaccessible } from '../utils/errors.js' 38: import { 39: coerceDescriptionToString, 40: type FrontmatterData, 41: type FrontmatterShell, 42: parseBooleanFrontmatter, 43: parseFrontmatter, 44: parseShellFrontmatter, 45: splitPathInFrontmatter, 46: } from '../utils/frontmatterParser.js' 47: import { getFsImplementation } from '../utils/fsOperations.js' 48: import { isPathGitignored } from '../utils/git/gitignore.js' 49: import { logError } from '../utils/log.js' 50: import { 51: extractDescriptionFromMarkdown, 52: getProjectDirsUpToHome, 53: loadMarkdownFilesForSubdir, 54: type MarkdownFile, 55: parseSlashCommandToolsFromFrontmatter, 56: } from '../utils/markdownConfigLoader.js' 57: import { parseUserSpecifiedModel } from '../utils/model/model.js' 58: import { executeShellCommandsInPrompt } from '../utils/promptShellExecution.js' 59: import type { SettingSource } from '../utils/settings/constants.js' 60: import { isSettingSourceEnabled } from '../utils/settings/constants.js' 61: import { getManagedFilePath } from '../utils/settings/managedPath.js' 62: import { isRestrictedToPluginOnly } from '../utils/settings/pluginOnlyPolicy.js' 63: import { HooksSchema, type HooksSettings } from '../utils/settings/types.js' 64: import { createSignal } from '../utils/signal.js' 65: import { registerMCPSkillBuilders } from './mcpSkillBuilders.js' 66: export type LoadedFrom = 67: | 'commands_DEPRECATED' 68: | 'skills' 69: | 'plugin' 70: | 'managed' 71: | 'bundled' 72: | 'mcp' 73: export function getSkillsPath( 74: source: SettingSource | 'plugin', 75: dir: 'skills' | 'commands', 76: ): string { 77: switch (source) { 78: case 'policySettings': 79: return join(getManagedFilePath(), '.claude', dir) 80: case 'userSettings': 81: return join(getClaudeConfigHomeDir(), dir) 82: case 'projectSettings': 83: return `.claude/${dir}` 84: case 'plugin': 85: return 'plugin' 86: default: 87: return '' 88: } 89: } 90: /** 91: * Estimates token count for a skill based on frontmatter only 92: * (name, description, whenToUse) since full content is only loaded on invocation. 93: */ 94: export function estimateSkillFrontmatterTokens(skill: Command): number { 95: const frontmatterText = [skill.name, skill.description, skill.whenToUse] 96: .filter(Boolean) 97: .join(' ') 98: return roughTokenCountEstimation(frontmatterText) 99: } 100: /** 101: * Gets a unique identifier for a file by resolving symlinks to a canonical path. 102: * This allows detection of duplicate files accessed through different paths 103: * (e.g., via symlinks or overlapping parent directories). 104: * Returns null if the file doesn't exist or can't be resolved. 105: * 106: * Uses realpath to resolve symlinks, which is filesystem-agnostic and avoids 107: * issues with filesystems that report unreliable inode values (e.g., inode 0 on 108: * some virtual/container/NFS filesystems, or precision loss on ExFAT). 109: * See: https: 110: */ 111: async function getFileIdentity(filePath: string): Promise<string | null> { 112: try { 113: return await realpath(filePath) 114: } catch { 115: return null 116: } 117: } 118: type SkillWithPath = { 119: skill: Command 120: filePath: string 121: } 122: function parseHooksFromFrontmatter( 123: frontmatter: FrontmatterData, 124: skillName: string, 125: ): HooksSettings | undefined { 126: if (!frontmatter.hooks) { 127: return undefined 128: } 129: const result = HooksSchema().safeParse(frontmatter.hooks) 130: if (!result.success) { 131: logForDebugging( 132: `Invalid hooks in skill '${skillName}': ${result.error.message}`, 133: ) 134: return undefined 135: } 136: return result.data 137: } 138: function parseSkillPaths(frontmatter: FrontmatterData): string[] | undefined { 139: if (!frontmatter.paths) { 140: return undefined 141: } 142: const patterns = splitPathInFrontmatter(frontmatter.paths) 143: .map(pattern => { 144: return pattern.endsWith('/**') ? pattern.slice(0, -3) : pattern 145: }) 146: .filter((p: string) => p.length > 0) 147: if (patterns.length === 0 || patterns.every((p: string) => p === '**')) { 148: return undefined 149: } 150: return patterns 151: } 152: export function parseSkillFrontmatterFields( 153: frontmatter: FrontmatterData, 154: markdownContent: string, 155: resolvedName: string, 156: descriptionFallbackLabel: 'Skill' | 'Custom command' = 'Skill', 157: ): { 158: displayName: string | undefined 159: description: string 160: hasUserSpecifiedDescription: boolean 161: allowedTools: string[] 162: argumentHint: string | undefined 163: argumentNames: string[] 164: whenToUse: string | undefined 165: version: string | undefined 166: model: ReturnType<typeof parseUserSpecifiedModel> | undefined 167: disableModelInvocation: boolean 168: userInvocable: boolean 169: hooks: HooksSettings | undefined 170: executionContext: 'fork' | undefined 171: agent: string | undefined 172: effort: EffortValue | undefined 173: shell: FrontmatterShell | undefined 174: } { 175: const validatedDescription = coerceDescriptionToString( 176: frontmatter.description, 177: resolvedName, 178: ) 179: const description = 180: validatedDescription ?? 181: extractDescriptionFromMarkdown(markdownContent, descriptionFallbackLabel) 182: const userInvocable = 183: frontmatter['user-invocable'] === undefined 184: ? true 185: : parseBooleanFrontmatter(frontmatter['user-invocable']) 186: const model = 187: frontmatter.model === 'inherit' 188: ? undefined 189: : frontmatter.model 190: ? parseUserSpecifiedModel(frontmatter.model as string) 191: : undefined 192: const effortRaw = frontmatter['effort'] 193: const effort = 194: effortRaw !== undefined ? parseEffortValue(effortRaw) : undefined 195: if (effortRaw !== undefined && effort === undefined) { 196: logForDebugging( 197: `Skill ${resolvedName} has invalid effort '${effortRaw}'. Valid options: ${EFFORT_LEVELS.join(', ')} or an integer`, 198: ) 199: } 200: return { 201: displayName: 202: frontmatter.name != null ? String(frontmatter.name) : undefined, 203: description, 204: hasUserSpecifiedDescription: validatedDescription !== null, 205: allowedTools: parseSlashCommandToolsFromFrontmatter( 206: frontmatter['allowed-tools'], 207: ), 208: argumentHint: 209: frontmatter['argument-hint'] != null 210: ? String(frontmatter['argument-hint']) 211: : undefined, 212: argumentNames: parseArgumentNames( 213: frontmatter.arguments as string | string[] | undefined, 214: ), 215: whenToUse: frontmatter.when_to_use as string | undefined, 216: version: frontmatter.version as string | undefined, 217: model, 218: disableModelInvocation: parseBooleanFrontmatter( 219: frontmatter['disable-model-invocation'], 220: ), 221: userInvocable, 222: hooks: parseHooksFromFrontmatter(frontmatter, resolvedName), 223: executionContext: frontmatter.context === 'fork' ? 'fork' : undefined, 224: agent: frontmatter.agent as string | undefined, 225: effort, 226: shell: parseShellFrontmatter(frontmatter.shell, resolvedName), 227: } 228: } 229: export function createSkillCommand({ 230: skillName, 231: displayName, 232: description, 233: hasUserSpecifiedDescription, 234: markdownContent, 235: allowedTools, 236: argumentHint, 237: argumentNames, 238: whenToUse, 239: version, 240: model, 241: disableModelInvocation, 242: userInvocable, 243: source, 244: baseDir, 245: loadedFrom, 246: hooks, 247: executionContext, 248: agent, 249: paths, 250: effort, 251: shell, 252: }: { 253: skillName: string 254: displayName: string | undefined 255: description: string 256: hasUserSpecifiedDescription: boolean 257: markdownContent: string 258: allowedTools: string[] 259: argumentHint: string | undefined 260: argumentNames: string[] 261: whenToUse: string | undefined 262: version: string | undefined 263: model: string | undefined 264: disableModelInvocation: boolean 265: userInvocable: boolean 266: source: PromptCommand['source'] 267: baseDir: string | undefined 268: loadedFrom: LoadedFrom 269: hooks: HooksSettings | undefined 270: executionContext: 'inline' | 'fork' | undefined 271: agent: string | undefined 272: paths: string[] | undefined 273: effort: EffortValue | undefined 274: shell: FrontmatterShell | undefined 275: }): Command { 276: return { 277: type: 'prompt', 278: name: skillName, 279: description, 280: hasUserSpecifiedDescription, 281: allowedTools, 282: argumentHint, 283: argNames: argumentNames.length > 0 ? argumentNames : undefined, 284: whenToUse, 285: version, 286: model, 287: disableModelInvocation, 288: userInvocable, 289: context: executionContext, 290: agent, 291: effort, 292: paths, 293: contentLength: markdownContent.length, 294: isHidden: !userInvocable, 295: progressMessage: 'running', 296: userFacingName(): string { 297: return displayName || skillName 298: }, 299: source, 300: loadedFrom, 301: hooks, 302: skillRoot: baseDir, 303: async getPromptForCommand(args, toolUseContext) { 304: let finalContent = baseDir 305: ? `Base directory for this skill: ${baseDir}\n\n${markdownContent}` 306: : markdownContent 307: finalContent = substituteArguments( 308: finalContent, 309: args, 310: true, 311: argumentNames, 312: ) 313: if (baseDir) { 314: const skillDir = 315: process.platform === 'win32' ? baseDir.replace(/\\/g, '/') : baseDir 316: finalContent = finalContent.replace(/\$\{CLAUDE_SKILL_DIR\}/g, skillDir) 317: } 318: finalContent = finalContent.replace( 319: /\$\{CLAUDE_SESSION_ID\}/g, 320: getSessionId(), 321: ) 322: if (loadedFrom !== 'mcp') { 323: finalContent = await executeShellCommandsInPrompt( 324: finalContent, 325: { 326: ...toolUseContext, 327: getAppState() { 328: const appState = toolUseContext.getAppState() 329: return { 330: ...appState, 331: toolPermissionContext: { 332: ...appState.toolPermissionContext, 333: alwaysAllowRules: { 334: ...appState.toolPermissionContext.alwaysAllowRules, 335: command: allowedTools, 336: }, 337: }, 338: } 339: }, 340: }, 341: `/${skillName}`, 342: shell, 343: ) 344: } 345: return [{ type: 'text', text: finalContent }] 346: }, 347: } satisfies Command 348: } 349: async function loadSkillsFromSkillsDir( 350: basePath: string, 351: source: SettingSource, 352: ): Promise<SkillWithPath[]> { 353: const fs = getFsImplementation() 354: let entries 355: try { 356: entries = await fs.readdir(basePath) 357: } catch (e: unknown) { 358: if (!isFsInaccessible(e)) logError(e) 359: return [] 360: } 361: const results = await Promise.all( 362: entries.map(async (entry): Promise<SkillWithPath | null> => { 363: try { 364: if (!entry.isDirectory() && !entry.isSymbolicLink()) { 365: return null 366: } 367: const skillDirPath = join(basePath, entry.name) 368: const skillFilePath = join(skillDirPath, 'SKILL.md') 369: let content: string 370: try { 371: content = await fs.readFile(skillFilePath, { encoding: 'utf-8' }) 372: } catch (e: unknown) { 373: if (!isENOENT(e)) { 374: logForDebugging(`[skills] failed to read ${skillFilePath}: ${e}`, { 375: level: 'warn', 376: }) 377: } 378: return null 379: } 380: const { frontmatter, content: markdownContent } = parseFrontmatter( 381: content, 382: skillFilePath, 383: ) 384: const skillName = entry.name 385: const parsed = parseSkillFrontmatterFields( 386: frontmatter, 387: markdownContent, 388: skillName, 389: ) 390: const paths = parseSkillPaths(frontmatter) 391: return { 392: skill: createSkillCommand({ 393: ...parsed, 394: skillName, 395: markdownContent, 396: source, 397: baseDir: skillDirPath, 398: loadedFrom: 'skills', 399: paths, 400: }), 401: filePath: skillFilePath, 402: } 403: } catch (error) { 404: logError(error) 405: return null 406: } 407: }), 408: ) 409: return results.filter((r): r is SkillWithPath => r !== null) 410: } 411: function isSkillFile(filePath: string): boolean { 412: return /^skill\.md$/i.test(basename(filePath)) 413: } 414: function transformSkillFiles(files: MarkdownFile[]): MarkdownFile[] { 415: const filesByDir = new Map<string, MarkdownFile[]>() 416: for (const file of files) { 417: const dir = dirname(file.filePath) 418: const dirFiles = filesByDir.get(dir) ?? [] 419: dirFiles.push(file) 420: filesByDir.set(dir, dirFiles) 421: } 422: const result: MarkdownFile[] = [] 423: for (const [dir, dirFiles] of filesByDir) { 424: const skillFiles = dirFiles.filter(f => isSkillFile(f.filePath)) 425: if (skillFiles.length > 0) { 426: const skillFile = skillFiles[0]! 427: if (skillFiles.length > 1) { 428: logForDebugging( 429: `Multiple skill files found in ${dir}, using ${basename(skillFile.filePath)}`, 430: ) 431: } 432: result.push(skillFile) 433: } else { 434: result.push(...dirFiles) 435: } 436: } 437: return result 438: } 439: function buildNamespace(targetDir: string, baseDir: string): string { 440: const normalizedBaseDir = baseDir.endsWith(pathSep) 441: ? baseDir.slice(0, -1) 442: : baseDir 443: if (targetDir === normalizedBaseDir) { 444: return '' 445: } 446: const relativePath = targetDir.slice(normalizedBaseDir.length + 1) 447: return relativePath ? relativePath.split(pathSep).join(':') : '' 448: } 449: function getSkillCommandName(filePath: string, baseDir: string): string { 450: const skillDirectory = dirname(filePath) 451: const parentOfSkillDir = dirname(skillDirectory) 452: const commandBaseName = basename(skillDirectory) 453: const namespace = buildNamespace(parentOfSkillDir, baseDir) 454: return namespace ? `${namespace}:${commandBaseName}` : commandBaseName 455: } 456: function getRegularCommandName(filePath: string, baseDir: string): string { 457: const fileName = basename(filePath) 458: const fileDirectory = dirname(filePath) 459: const commandBaseName = fileName.replace(/\.md$/, '') 460: const namespace = buildNamespace(fileDirectory, baseDir) 461: return namespace ? `${namespace}:${commandBaseName}` : commandBaseName 462: } 463: function getCommandName(file: MarkdownFile): string { 464: const isSkill = isSkillFile(file.filePath) 465: return isSkill 466: ? getSkillCommandName(file.filePath, file.baseDir) 467: : getRegularCommandName(file.filePath, file.baseDir) 468: } 469: /** 470: * Loads skills from legacy /commands/ directories. 471: * Supports both directory format (SKILL.md) and single .md file format. 472: * Commands from /commands/ default to user-invocable: true 473: */ 474: async function loadSkillsFromCommandsDir( 475: cwd: string, 476: ): Promise<SkillWithPath[]> { 477: try { 478: const markdownFiles = await loadMarkdownFilesForSubdir('commands', cwd) 479: const processedFiles = transformSkillFiles(markdownFiles) 480: const skills: SkillWithPath[] = [] 481: for (const { 482: baseDir, 483: filePath, 484: frontmatter, 485: content, 486: source, 487: } of processedFiles) { 488: try { 489: const isSkillFormat = isSkillFile(filePath) 490: const skillDirectory = isSkillFormat ? dirname(filePath) : undefined 491: const cmdName = getCommandName({ 492: baseDir, 493: filePath, 494: frontmatter, 495: content, 496: source, 497: }) 498: const parsed = parseSkillFrontmatterFields( 499: frontmatter, 500: content, 501: cmdName, 502: 'Custom command', 503: ) 504: skills.push({ 505: skill: createSkillCommand({ 506: ...parsed, 507: skillName: cmdName, 508: displayName: undefined, 509: markdownContent: content, 510: source, 511: baseDir: skillDirectory, 512: loadedFrom: 'commands_DEPRECATED', 513: paths: undefined, 514: }), 515: filePath, 516: }) 517: } catch (error) { 518: logError(error) 519: } 520: } 521: return skills 522: } catch (error) { 523: logError(error) 524: return [] 525: } 526: } 527: export const getSkillDirCommands = memoize( 528: async (cwd: string): Promise<Command[]> => { 529: const userSkillsDir = join(getClaudeConfigHomeDir(), 'skills') 530: const managedSkillsDir = join(getManagedFilePath(), '.claude', 'skills') 531: const projectSkillsDirs = getProjectDirsUpToHome('skills', cwd) 532: logForDebugging( 533: `Loading skills from: managed=${managedSkillsDir}, user=${userSkillsDir}, project=[${projectSkillsDirs.join(', ')}]`, 534: ) 535: const additionalDirs = getAdditionalDirectoriesForClaudeMd() 536: const skillsLocked = isRestrictedToPluginOnly('skills') 537: const projectSettingsEnabled = 538: isSettingSourceEnabled('projectSettings') && !skillsLocked 539: if (isBareMode()) { 540: if (additionalDirs.length === 0 || !projectSettingsEnabled) { 541: logForDebugging( 542: `[bare] Skipping skill dir discovery (${additionalDirs.length === 0 ? 'no --add-dir' : 'projectSettings disabled or skillsLocked'})`, 543: ) 544: return [] 545: } 546: const additionalSkillsNested = await Promise.all( 547: additionalDirs.map(dir => 548: loadSkillsFromSkillsDir( 549: join(dir, '.claude', 'skills'), 550: 'projectSettings', 551: ), 552: ), 553: ) 554: return additionalSkillsNested.flat().map(s => s.skill) 555: } 556: const [ 557: managedSkills, 558: userSkills, 559: projectSkillsNested, 560: additionalSkillsNested, 561: legacyCommands, 562: ] = await Promise.all([ 563: isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_POLICY_SKILLS) 564: ? Promise.resolve([]) 565: : loadSkillsFromSkillsDir(managedSkillsDir, 'policySettings'), 566: isSettingSourceEnabled('userSettings') && !skillsLocked 567: ? loadSkillsFromSkillsDir(userSkillsDir, 'userSettings') 568: : Promise.resolve([]), 569: projectSettingsEnabled 570: ? Promise.all( 571: projectSkillsDirs.map(dir => 572: loadSkillsFromSkillsDir(dir, 'projectSettings'), 573: ), 574: ) 575: : Promise.resolve([]), 576: projectSettingsEnabled 577: ? Promise.all( 578: additionalDirs.map(dir => 579: loadSkillsFromSkillsDir( 580: join(dir, '.claude', 'skills'), 581: 'projectSettings', 582: ), 583: ), 584: ) 585: : Promise.resolve([]), 586: skillsLocked ? Promise.resolve([]) : loadSkillsFromCommandsDir(cwd), 587: ]) 588: const allSkillsWithPaths = [ 589: ...managedSkills, 590: ...userSkills, 591: ...projectSkillsNested.flat(), 592: ...additionalSkillsNested.flat(), 593: ...legacyCommands, 594: ] 595: const fileIds = await Promise.all( 596: allSkillsWithPaths.map(({ skill, filePath }) => 597: skill.type === 'prompt' 598: ? getFileIdentity(filePath) 599: : Promise.resolve(null), 600: ), 601: ) 602: const seenFileIds = new Map< 603: string, 604: SettingSource | 'builtin' | 'mcp' | 'plugin' | 'bundled' 605: >() 606: const deduplicatedSkills: Command[] = [] 607: for (let i = 0; i < allSkillsWithPaths.length; i++) { 608: const entry = allSkillsWithPaths[i] 609: if (entry === undefined || entry.skill.type !== 'prompt') continue 610: const { skill } = entry 611: const fileId = fileIds[i] 612: if (fileId === null || fileId === undefined) { 613: deduplicatedSkills.push(skill) 614: continue 615: } 616: const existingSource = seenFileIds.get(fileId) 617: if (existingSource !== undefined) { 618: logForDebugging( 619: `Skipping duplicate skill '${skill.name}' from ${skill.source} (same file already loaded from ${existingSource})`, 620: ) 621: continue 622: } 623: seenFileIds.set(fileId, skill.source) 624: deduplicatedSkills.push(skill) 625: } 626: const duplicatesRemoved = 627: allSkillsWithPaths.length - deduplicatedSkills.length 628: if (duplicatesRemoved > 0) { 629: logForDebugging(`Deduplicated ${duplicatesRemoved} skills (same file)`) 630: } 631: const unconditionalSkills: Command[] = [] 632: const newConditionalSkills: Command[] = [] 633: for (const skill of deduplicatedSkills) { 634: if ( 635: skill.type === 'prompt' && 636: skill.paths && 637: skill.paths.length > 0 && 638: !activatedConditionalSkillNames.has(skill.name) 639: ) { 640: newConditionalSkills.push(skill) 641: } else { 642: unconditionalSkills.push(skill) 643: } 644: } 645: for (const skill of newConditionalSkills) { 646: conditionalSkills.set(skill.name, skill) 647: } 648: if (newConditionalSkills.length > 0) { 649: logForDebugging( 650: `[skills] ${newConditionalSkills.length} conditional skills stored (activated when matching files are touched)`, 651: ) 652: } 653: logForDebugging( 654: `Loaded ${deduplicatedSkills.length} unique skills (${unconditionalSkills.length} unconditional, ${newConditionalSkills.length} conditional, managed: ${managedSkills.length}, user: ${userSkills.length}, project: ${projectSkillsNested.flat().length}, additional: ${additionalSkillsNested.flat().length}, legacy commands: ${legacyCommands.length})`, 655: ) 656: return unconditionalSkills 657: }, 658: ) 659: export function clearSkillCaches() { 660: getSkillDirCommands.cache?.clear?.() 661: loadMarkdownFilesForSubdir.cache?.clear?.() 662: conditionalSkills.clear() 663: activatedConditionalSkillNames.clear() 664: } 665: export { getSkillDirCommands as getCommandDirCommands } 666: export { clearSkillCaches as clearCommandCaches } 667: export { transformSkillFiles } 668: const dynamicSkillDirs = new Set<string>() 669: const dynamicSkills = new Map<string, Command>() 670: const conditionalSkills = new Map<string, Command>() 671: const activatedConditionalSkillNames = new Set<string>() 672: const skillsLoaded = createSignal() 673: export function onDynamicSkillsLoaded(callback: () => void): () => void { 674: return skillsLoaded.subscribe(() => { 675: try { 676: callback() 677: } catch (error) { 678: logError(error) 679: } 680: }) 681: } 682: export async function discoverSkillDirsForPaths( 683: filePaths: string[], 684: cwd: string, 685: ): Promise<string[]> { 686: const fs = getFsImplementation() 687: const resolvedCwd = cwd.endsWith(pathSep) ? cwd.slice(0, -1) : cwd 688: const newDirs: string[] = [] 689: for (const filePath of filePaths) { 690: let currentDir = dirname(filePath) 691: while (currentDir.startsWith(resolvedCwd + pathSep)) { 692: const skillDir = join(currentDir, '.claude', 'skills') 693: if (!dynamicSkillDirs.has(skillDir)) { 694: dynamicSkillDirs.add(skillDir) 695: try { 696: await fs.stat(skillDir) 697: if (await isPathGitignored(currentDir, resolvedCwd)) { 698: logForDebugging( 699: `[skills] Skipped gitignored skills dir: ${skillDir}`, 700: ) 701: continue 702: } 703: newDirs.push(skillDir) 704: } catch { 705: } 706: } 707: const parent = dirname(currentDir) 708: if (parent === currentDir) break 709: currentDir = parent 710: } 711: } 712: return newDirs.sort( 713: (a, b) => b.split(pathSep).length - a.split(pathSep).length, 714: ) 715: } 716: export async function addSkillDirectories(dirs: string[]): Promise<void> { 717: if ( 718: !isSettingSourceEnabled('projectSettings') || 719: isRestrictedToPluginOnly('skills') 720: ) { 721: logForDebugging( 722: '[skills] Dynamic skill discovery skipped: projectSettings disabled or plugin-only policy', 723: ) 724: return 725: } 726: if (dirs.length === 0) { 727: return 728: } 729: const previousSkillNamesForLogging = new Set(dynamicSkills.keys()) 730: const loadedSkills = await Promise.all( 731: dirs.map(dir => loadSkillsFromSkillsDir(dir, 'projectSettings')), 732: ) 733: for (let i = loadedSkills.length - 1; i >= 0; i--) { 734: for (const { skill } of loadedSkills[i] ?? []) { 735: if (skill.type === 'prompt') { 736: dynamicSkills.set(skill.name, skill) 737: } 738: } 739: } 740: const newSkillCount = loadedSkills.flat().length 741: if (newSkillCount > 0) { 742: const addedSkills = [...dynamicSkills.keys()].filter( 743: n => !previousSkillNamesForLogging.has(n), 744: ) 745: logForDebugging( 746: `[skills] Dynamically discovered ${newSkillCount} skills from ${dirs.length} directories`, 747: ) 748: if (addedSkills.length > 0) { 749: logEvent('tengu_dynamic_skills_changed', { 750: source: 751: 'file_operation' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 752: previousCount: previousSkillNamesForLogging.size, 753: newCount: dynamicSkills.size, 754: addedCount: addedSkills.length, 755: directoryCount: dirs.length, 756: }) 757: } 758: } 759: skillsLoaded.emit() 760: } 761: export function getDynamicSkills(): Command[] { 762: return Array.from(dynamicSkills.values()) 763: } 764: export function activateConditionalSkillsForPaths( 765: filePaths: string[], 766: cwd: string, 767: ): string[] { 768: if (conditionalSkills.size === 0) { 769: return [] 770: } 771: const activated: string[] = [] 772: for (const [name, skill] of conditionalSkills) { 773: if (skill.type !== 'prompt' || !skill.paths || skill.paths.length === 0) { 774: continue 775: } 776: const skillIgnore = ignore().add(skill.paths) 777: for (const filePath of filePaths) { 778: const relativePath = isAbsolute(filePath) 779: ? relative(cwd, filePath) 780: : filePath 781: if ( 782: !relativePath || 783: relativePath.startsWith('..') || 784: isAbsolute(relativePath) 785: ) { 786: continue 787: } 788: if (skillIgnore.ignores(relativePath)) { 789: dynamicSkills.set(name, skill) 790: conditionalSkills.delete(name) 791: activatedConditionalSkillNames.add(name) 792: activated.push(name) 793: logForDebugging( 794: `[skills] Activated conditional skill '${name}' (matched path: ${relativePath})`, 795: ) 796: break 797: } 798: } 799: } 800: if (activated.length > 0) { 801: logEvent('tengu_dynamic_skills_changed', { 802: source: 803: 'conditional_paths' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 804: previousCount: dynamicSkills.size - activated.length, 805: newCount: dynamicSkills.size, 806: addedCount: activated.length, 807: directoryCount: 0, 808: }) 809: skillsLoaded.emit() 810: } 811: return activated 812: } 813: export function getConditionalSkillCount(): number { 814: return conditionalSkills.size 815: } 816: export function clearDynamicSkills(): void { 817: dynamicSkillDirs.clear() 818: dynamicSkills.clear() 819: conditionalSkills.clear() 820: activatedConditionalSkillNames.clear() 821: } 822: registerMCPSkillBuilders({ 823: createSkillCommand, 824: parseSkillFrontmatterFields, 825: })

File: src/skills/mcpSkillBuilders.ts

typescript 1: import type { 2: createSkillCommand, 3: parseSkillFrontmatterFields, 4: } from './loadSkillsDir.js' 5: export type MCPSkillBuilders = { 6: createSkillCommand: typeof createSkillCommand 7: parseSkillFrontmatterFields: typeof parseSkillFrontmatterFields 8: } 9: let builders: MCPSkillBuilders | null = null 10: export function registerMCPSkillBuilders(b: MCPSkillBuilders): void { 11: builders = b 12: } 13: export function getMCPSkillBuilders(): MCPSkillBuilders { 14: if (!builders) { 15: throw new Error( 16: 'MCP skill builders not registered — loadSkillsDir.ts has not been evaluated yet', 17: ) 18: } 19: return builders 20: }

File: src/state/AppState.tsx

typescript 1: import { c as _c } from "react/compiler-runtime"; 2: import { feature } from 'bun:bundle'; 3: import React, { useContext, useEffect, useEffectEvent, useState, useSyncExternalStore } from 'react'; 4: import { MailboxProvider } from '../context/mailbox.js'; 5: import { useSettingsChange } from '../hooks/useSettingsChange.js'; 6: import { logForDebugging } from '../utils/debug.js'; 7: import { createDisabledBypassPermissionsContext, isBypassPermissionsModeDisabled } from '../utils/permissions/permissionSetup.js'; 8: import { applySettingsChange } from '../utils/settings/applySettingsChange.js'; 9: import type { SettingSource } from '../utils/settings/constants.js'; 10: import { createStore } from './store.js'; 11: const VoiceProvider: (props: { 12: children: React.ReactNode; 13: }) => React.ReactNode = feature('VOICE_MODE') ? require('../context/voice.js').VoiceProvider : ({ 14: children 15: }) => children; 16: import { type AppState, type AppStateStore, getDefaultAppState } from './AppStateStore.js'; 17: export { type AppState, type AppStateStore, type CompletionBoundary, getDefaultAppState, IDLE_SPECULATION_STATE, type SpeculationResult, type SpeculationState } from './AppStateStore.js'; 18: export const AppStoreContext = React.createContext<AppStateStore | null>(null); 19: type Props = { 20: children: React.ReactNode; 21: initialState?: AppState; 22: onChangeAppState?: (args: { 23: newState: AppState; 24: oldState: AppState; 25: }) => void; 26: }; 27: const HasAppStateContext = React.createContext<boolean>(false); 28: export function AppStateProvider(t0) { 29: const $ = _c(13); 30: const { 31: children, 32: initialState, 33: onChangeAppState 34: } = t0; 35: const hasAppStateContext = useContext(HasAppStateContext); 36: if (hasAppStateContext) { 37: throw new Error("AppStateProvider can not be nested within another AppStateProvider"); 38: } 39: let t1; 40: if ($[0] !== initialState || $[1] !== onChangeAppState) { 41: t1 = () => createStore(initialState ?? getDefaultAppState(), onChangeAppState); 42: $[0] = initialState; 43: $[1] = onChangeAppState; 44: $[2] = t1; 45: } else { 46: t1 = $[2]; 47: } 48: const [store] = useState(t1); 49: let t2; 50: if ($[3] !== store) { 51: t2 = () => { 52: const { 53: toolPermissionContext 54: } = store.getState(); 55: if (toolPermissionContext.isBypassPermissionsModeAvailable && isBypassPermissionsModeDisabled()) { 56: logForDebugging("Disabling bypass permissions mode on mount (remote settings loaded before mount)"); 57: store.setState(_temp); 58: } 59: }; 60: $[3] = store; 61: $[4] = t2; 62: } else { 63: t2 = $[4]; 64: } 65: let t3; 66: if ($[5] === Symbol.for("react.memo_cache_sentinel")) { 67: t3 = []; 68: $[5] = t3; 69: } else { 70: t3 = $[5]; 71: } 72: useEffect(t2, t3); 73: let t4; 74: if ($[6] !== store.setState) { 75: t4 = source => applySettingsChange(source, store.setState); 76: $[6] = store.setState; 77: $[7] = t4; 78: } else { 79: t4 = $[7]; 80: } 81: const onSettingsChange = useEffectEvent(t4); 82: useSettingsChange(onSettingsChange); 83: let t5; 84: if ($[8] !== children) { 85: t5 = <MailboxProvider><VoiceProvider>{children}</VoiceProvider></MailboxProvider>; 86: $[8] = children; 87: $[9] = t5; 88: } else { 89: t5 = $[9]; 90: } 91: let t6; 92: if ($[10] !== store || $[11] !== t5) { 93: t6 = <HasAppStateContext.Provider value={true}><AppStoreContext.Provider value={store}>{t5}</AppStoreContext.Provider></HasAppStateContext.Provider>; 94: $[10] = store; 95: $[11] = t5; 96: $[12] = t6; 97: } else { 98: t6 = $[12]; 99: } 100: return t6; 101: } 102: function _temp(prev) { 103: return { 104: ...prev, 105: toolPermissionContext: createDisabledBypassPermissionsContext(prev.toolPermissionContext) 106: }; 107: } 108: function useAppStore(): AppStateStore { 109: const store = useContext(AppStoreContext); 110: if (!store) { 111: throw new ReferenceError('useAppState/useSetAppState cannot be called outside of an <AppStateProvider />'); 112: } 113: return store; 114: } 115: export function useAppState(selector) { 116: const $ = _c(3); 117: const store = useAppStore(); 118: let t0; 119: if ($[0] !== selector || $[1] !== store) { 120: t0 = () => { 121: const state = store.getState(); 122: const selected = selector(state); 123: if (false && state === selected) { 124: throw new Error(`Your selector in \`useAppState(${selector.toString()})\` returned the original state, which is not allowed. You must instead return a property for optimised rendering.`); 125: } 126: return selected; 127: }; 128: $[0] = selector; 129: $[1] = store; 130: $[2] = t0; 131: } else { 132: t0 = $[2]; 133: } 134: const get = t0; 135: return useSyncExternalStore(store.subscribe, get, get); 136: } 137: export function useSetAppState() { 138: return useAppStore().setState; 139: } 140: export function useAppStateStore() { 141: return useAppStore(); 142: } 143: const NOOP_SUBSCRIBE = () => () => {}; 144: export function useAppStateMaybeOutsideOfProvider(selector) { 145: const $ = _c(3); 146: const store = useContext(AppStoreContext); 147: let t0; 148: if ($[0] !== selector || $[1] !== store) { 149: t0 = () => store ? selector(store.getState()) : undefined; 150: $[0] = selector; 151: $[1] = store; 152: $[2] = t0; 153: } else { 154: t0 = $[2]; 155: } 156: return useSyncExternalStore(store ? store.subscribe : NOOP_SUBSCRIBE, t0); 157: }

File: src/state/AppStateStore.ts

typescript 1: import type { Notification } from 'src/context/notifications.js' 2: import type { TodoList } from 'src/utils/todo/types.js' 3: import type { BridgePermissionCallbacks } from '../bridge/bridgePermissionCallbacks.js' 4: import type { Command } from '../commands.js' 5: import type { ChannelPermissionCallbacks } from '../services/mcp/channelPermissions.js' 6: import type { ElicitationRequestEvent } from '../services/mcp/elicitationHandler.js' 7: import type { 8: MCPServerConnection, 9: ServerResource, 10: } from '../services/mcp/types.js' 11: import { shouldEnablePromptSuggestion } from '../services/PromptSuggestion/promptSuggestion.js' 12: import { 13: getEmptyToolPermissionContext, 14: type Tool, 15: type ToolPermissionContext, 16: } from '../Tool.js' 17: import type { TaskState } from '../tasks/types.js' 18: import type { AgentColorName } from '../tools/AgentTool/agentColorManager.js' 19: import type { AgentDefinitionsResult } from '../tools/AgentTool/loadAgentsDir.js' 20: import type { AllowedPrompt } from '../tools/ExitPlanModeTool/ExitPlanModeV2Tool.js' 21: import type { AgentId } from '../types/ids.js' 22: import type { Message, UserMessage } from '../types/message.js' 23: import type { LoadedPlugin, PluginError } from '../types/plugin.js' 24: import type { DeepImmutable } from '../types/utils.js' 25: import { 26: type AttributionState, 27: createEmptyAttributionState, 28: } from '../utils/commitAttribution.js' 29: import type { EffortValue } from '../utils/effort.js' 30: import type { FileHistoryState } from '../utils/fileHistory.js' 31: import type { REPLHookContext } from '../utils/hooks/postSamplingHooks.js' 32: import type { SessionHooksState } from '../utils/hooks/sessionHooks.js' 33: import type { ModelSetting } from '../utils/model/model.js' 34: import type { DenialTrackingState } from '../utils/permissions/denialTracking.js' 35: import type { PermissionMode } from '../utils/permissions/PermissionMode.js' 36: import { getInitialSettings } from '../utils/settings/settings.js' 37: import type { SettingsJson } from '../utils/settings/types.js' 38: import { shouldEnableThinkingByDefault } from '../utils/thinking.js' 39: import type { Store } from './store.js' 40: export type CompletionBoundary = 41: | { type: 'complete'; completedAt: number; outputTokens: number } 42: | { type: 'bash'; command: string; completedAt: number } 43: | { type: 'edit'; toolName: string; filePath: string; completedAt: number } 44: | { 45: type: 'denied_tool' 46: toolName: string 47: detail: string 48: completedAt: number 49: } 50: export type SpeculationResult = { 51: messages: Message[] 52: boundary: CompletionBoundary | null 53: timeSavedMs: number 54: } 55: export type SpeculationState = 56: | { status: 'idle' } 57: | { 58: status: 'active' 59: id: string 60: abort: () => void 61: startTime: number 62: messagesRef: { current: Message[] } 63: writtenPathsRef: { current: Set<string> } 64: boundary: CompletionBoundary | null 65: suggestionLength: number 66: toolUseCount: number 67: isPipelined: boolean 68: contextRef: { current: REPLHookContext } 69: pipelinedSuggestion?: { 70: text: string 71: promptId: 'user_intent' | 'stated_intent' 72: generationRequestId: string | null 73: } | null 74: } 75: export const IDLE_SPECULATION_STATE: SpeculationState = { status: 'idle' } 76: export type FooterItem = 77: | 'tasks' 78: | 'tmux' 79: | 'bagel' 80: | 'teams' 81: | 'bridge' 82: | 'companion' 83: export type AppState = DeepImmutable<{ 84: settings: SettingsJson 85: verbose: boolean 86: mainLoopModel: ModelSetting 87: mainLoopModelForSession: ModelSetting 88: statusLineText: string | undefined 89: expandedView: 'none' | 'tasks' | 'teammates' 90: isBriefOnly: boolean 91: showTeammateMessagePreview?: boolean 92: selectedIPAgentIndex: number 93: coordinatorTaskIndex: number 94: viewSelectionMode: 'none' | 'selecting-agent' | 'viewing-agent' 95: footerSelection: FooterItem | null 96: toolPermissionContext: ToolPermissionContext 97: spinnerTip?: string 98: agent: string | undefined 99: kairosEnabled: boolean 100: remoteSessionUrl: string | undefined 101: remoteConnectionStatus: 102: | 'connecting' 103: | 'connected' 104: | 'reconnecting' 105: | 'disconnected' 106: remoteBackgroundTaskCount: number 107: replBridgeEnabled: boolean 108: replBridgeExplicit: boolean 109: replBridgeOutboundOnly: boolean 110: replBridgeConnected: boolean 111: replBridgeSessionActive: boolean 112: replBridgeReconnecting: boolean 113: replBridgeConnectUrl: string | undefined 114: replBridgeSessionUrl: string | undefined 115: replBridgeEnvironmentId: string | undefined 116: replBridgeSessionId: string | undefined 117: replBridgeError: string | undefined 118: replBridgeInitialName: string | undefined 119: showRemoteCallout: boolean 120: }> & { 121: tasks: { [taskId: string]: TaskState } 122: agentNameRegistry: Map<string, AgentId> 123: foregroundedTaskId?: string 124: viewingAgentTaskId?: string 125: companionReaction?: string 126: companionPetAt?: number 127: mcp: { 128: clients: MCPServerConnection[] 129: tools: Tool[] 130: commands: Command[] 131: resources: Record<string, ServerResource[]> 132: pluginReconnectKey: number 133: } 134: plugins: { 135: enabled: LoadedPlugin[] 136: disabled: LoadedPlugin[] 137: commands: Command[] 138: errors: PluginError[] 139: installationStatus: { 140: marketplaces: Array<{ 141: name: string 142: status: 'pending' | 'installing' | 'installed' | 'failed' 143: error?: string 144: }> 145: plugins: Array<{ 146: id: string 147: name: string 148: status: 'pending' | 'installing' | 'installed' | 'failed' 149: error?: string 150: }> 151: } 152: needsRefresh: boolean 153: } 154: agentDefinitions: AgentDefinitionsResult 155: fileHistory: FileHistoryState 156: attribution: AttributionState 157: todos: { [agentId: string]: TodoList } 158: remoteAgentTaskSuggestions: { summary: string; task: string }[] 159: notifications: { 160: current: Notification | null 161: queue: Notification[] 162: } 163: elicitation: { 164: queue: ElicitationRequestEvent[] 165: } 166: thinkingEnabled: boolean | undefined 167: promptSuggestionEnabled: boolean 168: sessionHooks: SessionHooksState 169: tungstenActiveSession?: { 170: sessionName: string 171: socketName: string 172: target: string 173: } 174: tungstenLastCapturedTime?: number 175: tungstenLastCommand?: { 176: command: string 177: timestamp: number 178: } 179: tungstenPanelVisible?: boolean 180: tungstenPanelAutoHidden?: boolean 181: bagelActive?: boolean 182: bagelUrl?: string 183: bagelPanelVisible?: boolean 184: computerUseMcpState?: { 185: allowedApps?: readonly { 186: bundleId: string 187: displayName: string 188: grantedAt: number 189: }[] 190: grantFlags?: { 191: clipboardRead: boolean 192: clipboardWrite: boolean 193: systemKeyCombos: boolean 194: } 195: lastScreenshotDims?: { 196: width: number 197: height: number 198: displayWidth: number 199: displayHeight: number 200: displayId?: number 201: originX?: number 202: originY?: number 203: } 204: hiddenDuringTurn?: ReadonlySet<string> 205: selectedDisplayId?: number 206: displayPinnedByModel?: boolean 207: displayResolvedForApps?: string 208: } 209: replContext?: { 210: vmContext: import('vm').Context 211: registeredTools: Map< 212: string, 213: { 214: name: string 215: description: string 216: schema: Record<string, unknown> 217: handler: (args: Record<string, unknown>) => Promise<unknown> 218: } 219: > 220: console: { 221: log: (...args: unknown[]) => void 222: error: (...args: unknown[]) => void 223: warn: (...args: unknown[]) => void 224: info: (...args: unknown[]) => void 225: debug: (...args: unknown[]) => void 226: getStdout: () => string 227: getStderr: () => string 228: clear: () => void 229: } 230: } 231: teamContext?: { 232: teamName: string 233: teamFilePath: string 234: leadAgentId: string 235: selfAgentId?: string 236: selfAgentName?: string 237: isLeader?: boolean 238: selfAgentColor?: string 239: teammates: { 240: [teammateId: string]: { 241: name: string 242: agentType?: string 243: color?: string 244: tmuxSessionName: string 245: tmuxPaneId: string 246: cwd: string 247: worktreePath?: string 248: spawnedAt: number 249: } 250: } 251: } 252: standaloneAgentContext?: { 253: name: string 254: color?: AgentColorName 255: } 256: inbox: { 257: messages: Array<{ 258: id: string 259: from: string 260: text: string 261: timestamp: string 262: status: 'pending' | 'processing' | 'processed' 263: color?: string 264: summary?: string 265: }> 266: } 267: workerSandboxPermissions: { 268: queue: Array<{ 269: requestId: string 270: workerId: string 271: workerName: string 272: workerColor?: string 273: host: string 274: createdAt: number 275: }> 276: selectedIndex: number 277: } 278: pendingWorkerRequest: { 279: toolName: string 280: toolUseId: string 281: description: string 282: } | null 283: pendingSandboxRequest: { 284: requestId: string 285: host: string 286: } | null 287: promptSuggestion: { 288: text: string | null 289: promptId: 'user_intent' | 'stated_intent' | null 290: shownAt: number 291: acceptedAt: number 292: generationRequestId: string | null 293: } 294: speculation: SpeculationState 295: speculationSessionTimeSavedMs: number 296: skillImprovement: { 297: suggestion: { 298: skillName: string 299: updates: { section: string; change: string; reason: string }[] 300: } | null 301: } 302: authVersion: number 303: initialMessage: { 304: message: UserMessage 305: clearContext?: boolean 306: mode?: PermissionMode 307: allowedPrompts?: AllowedPrompt[] 308: } | null 309: pendingPlanVerification?: { 310: plan: string 311: verificationStarted: boolean 312: verificationCompleted: boolean 313: } 314: denialTracking?: DenialTrackingState 315: activeOverlays: ReadonlySet<string> 316: fastMode?: boolean 317: advisorModel?: string 318: effortValue?: EffortValue 319: ultraplanLaunching?: boolean 320: ultraplanSessionUrl?: string 321: ultraplanPendingChoice?: { plan: string; sessionId: string; taskId: string } 322: ultraplanLaunchPending?: { blurb: string } 323: isUltraplanMode?: boolean 324: replBridgePermissionCallbacks?: BridgePermissionCallbacks 325: channelPermissionCallbacks?: ChannelPermissionCallbacks 326: } 327: export type AppStateStore = Store<AppState> 328: export function getDefaultAppState(): AppState { 329: const teammateUtils = 330: require('../utils/teammate.js') as typeof import('../utils/teammate.js') 331: const initialMode: PermissionMode = 332: teammateUtils.isTeammate() && teammateUtils.isPlanModeRequired() 333: ? 'plan' 334: : 'default' 335: return { 336: settings: getInitialSettings(), 337: tasks: {}, 338: agentNameRegistry: new Map(), 339: verbose: false, 340: mainLoopModel: null, 341: mainLoopModelForSession: null, 342: statusLineText: undefined, 343: expandedView: 'none', 344: isBriefOnly: false, 345: showTeammateMessagePreview: false, 346: selectedIPAgentIndex: -1, 347: coordinatorTaskIndex: -1, 348: viewSelectionMode: 'none', 349: footerSelection: null, 350: kairosEnabled: false, 351: remoteSessionUrl: undefined, 352: remoteConnectionStatus: 'connecting', 353: remoteBackgroundTaskCount: 0, 354: replBridgeEnabled: false, 355: replBridgeExplicit: false, 356: replBridgeOutboundOnly: false, 357: replBridgeConnected: false, 358: replBridgeSessionActive: false, 359: replBridgeReconnecting: false, 360: replBridgeConnectUrl: undefined, 361: replBridgeSessionUrl: undefined, 362: replBridgeEnvironmentId: undefined, 363: replBridgeSessionId: undefined, 364: replBridgeError: undefined, 365: replBridgeInitialName: undefined, 366: showRemoteCallout: false, 367: toolPermissionContext: { 368: ...getEmptyToolPermissionContext(), 369: mode: initialMode, 370: }, 371: agent: undefined, 372: agentDefinitions: { activeAgents: [], allAgents: [] }, 373: fileHistory: { 374: snapshots: [], 375: trackedFiles: new Set(), 376: snapshotSequence: 0, 377: }, 378: attribution: createEmptyAttributionState(), 379: mcp: { 380: clients: [], 381: tools: [], 382: commands: [], 383: resources: {}, 384: pluginReconnectKey: 0, 385: }, 386: plugins: { 387: enabled: [], 388: disabled: [], 389: commands: [], 390: errors: [], 391: installationStatus: { 392: marketplaces: [], 393: plugins: [], 394: }, 395: needsRefresh: false, 396: }, 397: todos: {}, 398: remoteAgentTaskSuggestions: [], 399: notifications: { 400: current: null, 401: queue: [], 402: }, 403: elicitation: { 404: queue: [], 405: }, 406: thinkingEnabled: shouldEnableThinkingByDefault(), 407: promptSuggestionEnabled: shouldEnablePromptSuggestion(), 408: sessionHooks: new Map(), 409: inbox: { 410: messages: [], 411: }, 412: workerSandboxPermissions: { 413: queue: [], 414: selectedIndex: 0, 415: }, 416: pendingWorkerRequest: null, 417: pendingSandboxRequest: null, 418: promptSuggestion: { 419: text: null, 420: promptId: null, 421: shownAt: 0, 422: acceptedAt: 0, 423: generationRequestId: null, 424: }, 425: speculation: IDLE_SPECULATION_STATE, 426: speculationSessionTimeSavedMs: 0, 427: skillImprovement: { 428: suggestion: null, 429: }, 430: authVersion: 0, 431: initialMessage: null, 432: effortValue: undefined, 433: activeOverlays: new Set<string>(), 434: fastMode: false, 435: } 436: }

File: src/state/onChangeAppState.ts

typescript 1: import { setMainLoopModelOverride } from '../bootstrap/state.js' 2: import { 3: clearApiKeyHelperCache, 4: clearAwsCredentialsCache, 5: clearGcpCredentialsCache, 6: } from '../utils/auth.js' 7: import { getGlobalConfig, saveGlobalConfig } from '../utils/config.js' 8: import { toError } from '../utils/errors.js' 9: import { logError } from '../utils/log.js' 10: import { applyConfigEnvironmentVariables } from '../utils/managedEnv.js' 11: import { 12: permissionModeFromString, 13: toExternalPermissionMode, 14: } from '../utils/permissions/PermissionMode.js' 15: import { 16: notifyPermissionModeChanged, 17: notifySessionMetadataChanged, 18: type SessionExternalMetadata, 19: } from '../utils/sessionState.js' 20: import { updateSettingsForSource } from '../utils/settings/settings.js' 21: import type { AppState } from './AppStateStore.js' 22: export function externalMetadataToAppState( 23: metadata: SessionExternalMetadata, 24: ): (prev: AppState) => AppState { 25: return prev => ({ 26: ...prev, 27: ...(typeof metadata.permission_mode === 'string' 28: ? { 29: toolPermissionContext: { 30: ...prev.toolPermissionContext, 31: mode: permissionModeFromString(metadata.permission_mode), 32: }, 33: } 34: : {}), 35: ...(typeof metadata.is_ultraplan_mode === 'boolean' 36: ? { isUltraplanMode: metadata.is_ultraplan_mode } 37: : {}), 38: }) 39: } 40: export function onChangeAppState({ 41: newState, 42: oldState, 43: }: { 44: newState: AppState 45: oldState: AppState 46: }) { 47: const prevMode = oldState.toolPermissionContext.mode 48: const newMode = newState.toolPermissionContext.mode 49: if (prevMode !== newMode) { 50: const prevExternal = toExternalPermissionMode(prevMode) 51: const newExternal = toExternalPermissionMode(newMode) 52: if (prevExternal !== newExternal) { 53: const isUltraplan = 54: newExternal === 'plan' && 55: newState.isUltraplanMode && 56: !oldState.isUltraplanMode 57: ? true 58: : null 59: notifySessionMetadataChanged({ 60: permission_mode: newExternal, 61: is_ultraplan_mode: isUltraplan, 62: }) 63: } 64: notifyPermissionModeChanged(newMode) 65: } 66: if ( 67: newState.mainLoopModel !== oldState.mainLoopModel && 68: newState.mainLoopModel === null 69: ) { 70: updateSettingsForSource('userSettings', { model: undefined }) 71: setMainLoopModelOverride(null) 72: } 73: if ( 74: newState.mainLoopModel !== oldState.mainLoopModel && 75: newState.mainLoopModel !== null 76: ) { 77: updateSettingsForSource('userSettings', { model: newState.mainLoopModel }) 78: setMainLoopModelOverride(newState.mainLoopModel) 79: } 80: if (newState.expandedView !== oldState.expandedView) { 81: const showExpandedTodos = newState.expandedView === 'tasks' 82: const showSpinnerTree = newState.expandedView === 'teammates' 83: if ( 84: getGlobalConfig().showExpandedTodos !== showExpandedTodos || 85: getGlobalConfig().showSpinnerTree !== showSpinnerTree 86: ) { 87: saveGlobalConfig(current => ({ 88: ...current, 89: showExpandedTodos, 90: showSpinnerTree, 91: })) 92: } 93: } 94: if ( 95: newState.verbose !== oldState.verbose && 96: getGlobalConfig().verbose !== newState.verbose 97: ) { 98: const verbose = newState.verbose 99: saveGlobalConfig(current => ({ 100: ...current, 101: verbose, 102: })) 103: } 104: if (process.env.USER_TYPE === 'ant') { 105: if ( 106: newState.tungstenPanelVisible !== oldState.tungstenPanelVisible && 107: newState.tungstenPanelVisible !== undefined && 108: getGlobalConfig().tungstenPanelVisible !== newState.tungstenPanelVisible 109: ) { 110: const tungstenPanelVisible = newState.tungstenPanelVisible 111: saveGlobalConfig(current => ({ ...current, tungstenPanelVisible })) 112: } 113: } 114: if (newState.settings !== oldState.settings) { 115: try { 116: clearApiKeyHelperCache() 117: clearAwsCredentialsCache() 118: clearGcpCredentialsCache() 119: if (newState.settings.env !== oldState.settings.env) { 120: applyConfigEnvironmentVariables() 121: } 122: } catch (error) { 123: logError(toError(error)) 124: } 125: } 126: }

File: src/state/selectors.ts

typescript 1: import type { InProcessTeammateTaskState } from '../tasks/InProcessTeammateTask/types.js' 2: import { isInProcessTeammateTask } from '../tasks/InProcessTeammateTask/types.js' 3: import type { LocalAgentTaskState } from '../tasks/LocalAgentTask/LocalAgentTask.js' 4: import type { AppState } from './AppStateStore.js' 5: export function getViewedTeammateTask( 6: appState: Pick<AppState, 'viewingAgentTaskId' | 'tasks'>, 7: ): InProcessTeammateTaskState | undefined { 8: const { viewingAgentTaskId, tasks } = appState 9: if (!viewingAgentTaskId) { 10: return undefined 11: } 12: const task = tasks[viewingAgentTaskId] 13: if (!task) { 14: return undefined 15: } 16: if (!isInProcessTeammateTask(task)) { 17: return undefined 18: } 19: return task 20: } 21: export type ActiveAgentForInput = 22: | { type: 'leader' } 23: | { type: 'viewed'; task: InProcessTeammateTaskState } 24: | { type: 'named_agent'; task: LocalAgentTaskState } 25: export function getActiveAgentForInput( 26: appState: AppState, 27: ): ActiveAgentForInput { 28: const viewedTask = getViewedTeammateTask(appState) 29: if (viewedTask) { 30: return { type: 'viewed', task: viewedTask } 31: } 32: const { viewingAgentTaskId, tasks } = appState 33: if (viewingAgentTaskId) { 34: const task = tasks[viewingAgentTaskId] 35: if (task?.type === 'local_agent') { 36: return { type: 'named_agent', task } 37: } 38: } 39: return { type: 'leader' } 40: }

File: src/state/store.ts

typescript 1: type Listener = () => void 2: type OnChange<T> = (args: { newState: T; oldState: T }) => void 3: export type Store<T> = { 4: getState: () => T 5: setState: (updater: (prev: T) => T) => void 6: subscribe: (listener: Listener) => () => void 7: } 8: export function createStore<T>( 9: initialState: T, 10: onChange?: OnChange<T>, 11: ): Store<T> { 12: let state = initialState 13: const listeners = new Set<Listener>() 14: return { 15: getState: () => state, 16: setState: (updater: (prev: T) => T) => { 17: const prev = state 18: const next = updater(prev) 19: if (Object.is(next, prev)) return 20: state = next 21: onChange?.({ newState: next, oldState: prev }) 22: for (const listener of listeners) listener() 23: }, 24: subscribe: (listener: Listener) => { 25: listeners.add(listener) 26: return () => listeners.delete(listener) 27: }, 28: } 29: }

File: src/state/teammateViewHelpers.ts

typescript 1: import { logEvent } from '../services/analytics/index.js' 2: import { isTerminalTaskStatus } from '../Task.js' 3: import type { LocalAgentTaskState } from '../tasks/LocalAgentTask/LocalAgentTask.js' 4: const PANEL_GRACE_MS = 30_000 5: import type { AppState } from './AppState.js' 6: function isLocalAgent(task: unknown): task is LocalAgentTaskState { 7: return ( 8: typeof task === 'object' && 9: task !== null && 10: 'type' in task && 11: task.type === 'local_agent' 12: ) 13: } 14: function release(task: LocalAgentTaskState): LocalAgentTaskState { 15: return { 16: ...task, 17: retain: false, 18: messages: undefined, 19: diskLoaded: false, 20: evictAfter: isTerminalTaskStatus(task.status) 21: ? Date.now() + PANEL_GRACE_MS 22: : undefined, 23: } 24: } 25: export function enterTeammateView( 26: taskId: string, 27: setAppState: (updater: (prev: AppState) => AppState) => void, 28: ): void { 29: logEvent('tengu_transcript_view_enter', {}) 30: setAppState(prev => { 31: const task = prev.tasks[taskId] 32: const prevId = prev.viewingAgentTaskId 33: const prevTask = prevId !== undefined ? prev.tasks[prevId] : undefined 34: const switching = 35: prevId !== undefined && 36: prevId !== taskId && 37: isLocalAgent(prevTask) && 38: prevTask.retain 39: const needsRetain = 40: isLocalAgent(task) && (!task.retain || task.evictAfter !== undefined) 41: const needsView = 42: prev.viewingAgentTaskId !== taskId || 43: prev.viewSelectionMode !== 'viewing-agent' 44: if (!needsRetain && !needsView && !switching) return prev 45: let tasks = prev.tasks 46: if (switching || needsRetain) { 47: tasks = { ...prev.tasks } 48: if (switching) tasks[prevId] = release(prevTask) 49: if (needsRetain) { 50: tasks[taskId] = { ...task, retain: true, evictAfter: undefined } 51: } 52: } 53: return { 54: ...prev, 55: viewingAgentTaskId: taskId, 56: viewSelectionMode: 'viewing-agent', 57: tasks, 58: } 59: }) 60: } 61: export function exitTeammateView( 62: setAppState: (updater: (prev: AppState) => AppState) => void, 63: ): void { 64: logEvent('tengu_transcript_view_exit', {}) 65: setAppState(prev => { 66: const id = prev.viewingAgentTaskId 67: const cleared = { 68: ...prev, 69: viewingAgentTaskId: undefined, 70: viewSelectionMode: 'none' as const, 71: } 72: if (id === undefined) { 73: return prev.viewSelectionMode === 'none' ? prev : cleared 74: } 75: const task = prev.tasks[id] 76: if (!isLocalAgent(task) || !task.retain) return cleared 77: return { 78: ...cleared, 79: tasks: { ...prev.tasks, [id]: release(task) }, 80: } 81: }) 82: } 83: export function stopOrDismissAgent( 84: taskId: string, 85: setAppState: (updater: (prev: AppState) => AppState) => void, 86: ): void { 87: setAppState(prev => { 88: const task = prev.tasks[taskId] 89: if (!isLocalAgent(task)) return prev 90: if (task.status === 'running') { 91: task.abortController?.abort() 92: return prev 93: } 94: if (task.evictAfter === 0) return prev 95: const viewingThis = prev.viewingAgentTaskId === taskId 96: return { 97: ...prev, 98: tasks: { 99: ...prev.tasks, 100: [taskId]: { ...release(task), evictAfter: 0 }, 101: }, 102: ...(viewingThis && { 103: viewingAgentTaskId: undefined, 104: viewSelectionMode: 'none', 105: }), 106: } 107: }) 108: }

File: src/tasks/DreamTask/DreamTask.ts

typescript 1: import { rollbackConsolidationLock } from '../../services/autoDream/consolidationLock.js' 2: import type { SetAppState, Task, TaskStateBase } from '../../Task.js' 3: import { createTaskStateBase, generateTaskId } from '../../Task.js' 4: import { registerTask, updateTaskState } from '../../utils/task/framework.js' 5: const MAX_TURNS = 30 6: export type DreamTurn = { 7: text: string 8: toolUseCount: number 9: } 10: export type DreamPhase = 'starting' | 'updating' 11: export type DreamTaskState = TaskStateBase & { 12: type: 'dream' 13: phase: DreamPhase 14: sessionsReviewing: number 15: filesTouched: string[] 16: turns: DreamTurn[] 17: abortController?: AbortController 18: priorMtime: number 19: } 20: export function isDreamTask(task: unknown): task is DreamTaskState { 21: return ( 22: typeof task === 'object' && 23: task !== null && 24: 'type' in task && 25: task.type === 'dream' 26: ) 27: } 28: export function registerDreamTask( 29: setAppState: SetAppState, 30: opts: { 31: sessionsReviewing: number 32: priorMtime: number 33: abortController: AbortController 34: }, 35: ): string { 36: const id = generateTaskId('dream') 37: const task: DreamTaskState = { 38: ...createTaskStateBase(id, 'dream', 'dreaming'), 39: type: 'dream', 40: status: 'running', 41: phase: 'starting', 42: sessionsReviewing: opts.sessionsReviewing, 43: filesTouched: [], 44: turns: [], 45: abortController: opts.abortController, 46: priorMtime: opts.priorMtime, 47: } 48: registerTask(task, setAppState) 49: return id 50: } 51: export function addDreamTurn( 52: taskId: string, 53: turn: DreamTurn, 54: touchedPaths: string[], 55: setAppState: SetAppState, 56: ): void { 57: updateTaskState<DreamTaskState>(taskId, setAppState, task => { 58: const seen = new Set(task.filesTouched) 59: const newTouched = touchedPaths.filter(p => !seen.has(p) && seen.add(p)) 60: if ( 61: turn.text === '' && 62: turn.toolUseCount === 0 && 63: newTouched.length === 0 64: ) { 65: return task 66: } 67: return { 68: ...task, 69: phase: newTouched.length > 0 ? 'updating' : task.phase, 70: filesTouched: 71: newTouched.length > 0 72: ? [...task.filesTouched, ...newTouched] 73: : task.filesTouched, 74: turns: task.turns.slice(-(MAX_TURNS - 1)).concat(turn), 75: } 76: }) 77: } 78: export function completeDreamTask( 79: taskId: string, 80: setAppState: SetAppState, 81: ): void { 82: updateTaskState<DreamTaskState>(taskId, setAppState, task => ({ 83: ...task, 84: status: 'completed', 85: endTime: Date.now(), 86: notified: true, 87: abortController: undefined, 88: })) 89: } 90: export function failDreamTask(taskId: string, setAppState: SetAppState): void { 91: updateTaskState<DreamTaskState>(taskId, setAppState, task => ({ 92: ...task, 93: status: 'failed', 94: endTime: Date.now(), 95: notified: true, 96: abortController: undefined, 97: })) 98: } 99: export const DreamTask: Task = { 100: name: 'DreamTask', 101: type: 'dream', 102: async kill(taskId, setAppState) { 103: let priorMtime: number | undefined 104: updateTaskState<DreamTaskState>(taskId, setAppState, task => { 105: if (task.status !== 'running') return task 106: task.abortController?.abort() 107: priorMtime = task.priorMtime 108: return { 109: ...task, 110: status: 'killed', 111: endTime: Date.now(), 112: notified: true, 113: abortController: undefined, 114: } 115: }) 116: if (priorMtime !== undefined) { 117: await rollbackConsolidationLock(priorMtime) 118: } 119: }, 120: }

File: src/tasks/InProcessTeammateTask/InProcessTeammateTask.tsx

typescript 1: import { isTerminalTaskStatus, type SetAppState, type Task, type TaskStateBase } from '../../Task.js'; 2: import type { Message } from '../../types/message.js'; 3: import { logForDebugging } from '../../utils/debug.js'; 4: import { createUserMessage } from '../../utils/messages.js'; 5: import { killInProcessTeammate } from '../../utils/swarm/spawnInProcess.js'; 6: import { updateTaskState } from '../../utils/task/framework.js'; 7: import type { InProcessTeammateTaskState } from './types.js'; 8: import { appendCappedMessage, isInProcessTeammateTask } from './types.js'; 9: export const InProcessTeammateTask: Task = { 10: name: 'InProcessTeammateTask', 11: type: 'in_process_teammate', 12: async kill(taskId, setAppState) { 13: killInProcessTeammate(taskId, setAppState); 14: } 15: }; 16: export function requestTeammateShutdown(taskId: string, setAppState: SetAppState): void { 17: updateTaskState<InProcessTeammateTaskState>(taskId, setAppState, task => { 18: if (task.status !== 'running' || task.shutdownRequested) { 19: return task; 20: } 21: return { 22: ...task, 23: shutdownRequested: true 24: }; 25: }); 26: } 27: export function appendTeammateMessage(taskId: string, message: Message, setAppState: SetAppState): void { 28: updateTaskState<InProcessTeammateTaskState>(taskId, setAppState, task => { 29: if (task.status !== 'running') { 30: return task; 31: } 32: return { 33: ...task, 34: messages: appendCappedMessage(task.messages, message) 35: }; 36: }); 37: } 38: export function injectUserMessageToTeammate(taskId: string, message: string, setAppState: SetAppState): void { 39: updateTaskState<InProcessTeammateTaskState>(taskId, setAppState, task => { 40: if (isTerminalTaskStatus(task.status)) { 41: logForDebugging(`Dropping message for teammate task ${taskId}: task status is "${task.status}"`); 42: return task; 43: } 44: return { 45: ...task, 46: pendingUserMessages: [...task.pendingUserMessages, message], 47: messages: appendCappedMessage(task.messages, createUserMessage({ 48: content: message 49: })) 50: }; 51: }); 52: } 53: export function findTeammateTaskByAgentId(agentId: string, tasks: Record<string, TaskStateBase>): InProcessTeammateTaskState | undefined { 54: let fallback: InProcessTeammateTaskState | undefined; 55: for (const task of Object.values(tasks)) { 56: if (isInProcessTeammateTask(task) && task.identity.agentId === agentId) { 57: if (task.status === 'running') { 58: return task; 59: } 60: if (!fallback) { 61: fallback = task; 62: } 63: } 64: } 65: return fallback; 66: } 67: export function getAllInProcessTeammateTasks(tasks: Record<string, TaskStateBase>): InProcessTeammateTaskState[] { 68: return Object.values(tasks).filter(isInProcessTeammateTask); 69: } 70: export function getRunningTeammatesSorted(tasks: Record<string, TaskStateBase>): InProcessTeammateTaskState[] { 71: return getAllInProcessTeammateTasks(tasks).filter(t => t.status === 'running').sort((a, b) => a.identity.agentName.localeCompare(b.identity.agentName)); 72: }

File: src/tasks/InProcessTeammateTask/types.ts

typescript 1: import type { TaskStateBase } from '../../Task.js' 2: import type { AgentToolResult } from '../../tools/AgentTool/agentToolUtils.js' 3: import type { AgentDefinition } from '../../tools/AgentTool/loadAgentsDir.js' 4: import type { Message } from '../../types/message.js' 5: import type { PermissionMode } from '../../utils/permissions/PermissionMode.js' 6: import type { AgentProgress } from '../LocalAgentTask/LocalAgentTask.js' 7: export type TeammateIdentity = { 8: agentId: string 9: agentName: string 10: teamName: string 11: color?: string 12: planModeRequired: boolean 13: parentSessionId: string 14: } 15: export type InProcessTeammateTaskState = TaskStateBase & { 16: type: 'in_process_teammate' 17: identity: TeammateIdentity 18: prompt: string 19: model?: string 20: selectedAgent?: AgentDefinition 21: abortController?: AbortController 22: currentWorkAbortController?: AbortController 23: unregisterCleanup?: () => void 24: awaitingPlanApproval: boolean 25: permissionMode: PermissionMode 26: error?: string 27: result?: AgentToolResult 28: progress?: AgentProgress 29: messages?: Message[] 30: inProgressToolUseIDs?: Set<string> 31: pendingUserMessages: string[] 32: spinnerVerb?: string 33: pastTenseVerb?: string 34: isIdle: boolean 35: shutdownRequested: boolean 36: onIdleCallbacks?: Array<() => void> 37: lastReportedToolCount: number 38: lastReportedTokenCount: number 39: } 40: export function isInProcessTeammateTask( 41: task: unknown, 42: ): task is InProcessTeammateTaskState { 43: return ( 44: typeof task === 'object' && 45: task !== null && 46: 'type' in task && 47: task.type === 'in_process_teammate' 48: ) 49: } 50: export const TEAMMATE_MESSAGES_UI_CAP = 50 51: export function appendCappedMessage<T>( 52: prev: readonly T[] | undefined, 53: item: T, 54: ): T[] { 55: if (prev === undefined || prev.length === 0) { 56: return [item] 57: } 58: if (prev.length >= TEAMMATE_MESSAGES_UI_CAP) { 59: const next = prev.slice(-(TEAMMATE_MESSAGES_UI_CAP - 1)) 60: next.push(item) 61: return next 62: } 63: return [...prev, item] 64: }

File: src/tasks/LocalAgentTask/LocalAgentTask.tsx

typescript 1: import { getSdkAgentProgressSummariesEnabled } from '../../bootstrap/state.js'; 2: import { OUTPUT_FILE_TAG, STATUS_TAG, SUMMARY_TAG, TASK_ID_TAG, TASK_NOTIFICATION_TAG, TOOL_USE_ID_TAG, WORKTREE_BRANCH_TAG, WORKTREE_PATH_TAG, WORKTREE_TAG } from '../../constants/xml.js'; 3: import { abortSpeculation } from '../../services/PromptSuggestion/speculation.js'; 4: import type { AppState } from '../../state/AppState.js'; 5: import type { SetAppState, Task, TaskStateBase } from '../../Task.js'; 6: import { createTaskStateBase } from '../../Task.js'; 7: import type { Tools } from '../../Tool.js'; 8: import { findToolByName } from '../../Tool.js'; 9: import type { AgentToolResult } from '../../tools/AgentTool/agentToolUtils.js'; 10: import type { AgentDefinition } from '../../tools/AgentTool/loadAgentsDir.js'; 11: import { SYNTHETIC_OUTPUT_TOOL_NAME } from '../../tools/SyntheticOutputTool/SyntheticOutputTool.js'; 12: import { asAgentId } from '../../types/ids.js'; 13: import type { Message } from '../../types/message.js'; 14: import { createAbortController, createChildAbortController } from '../../utils/abortController.js'; 15: import { registerCleanup } from '../../utils/cleanupRegistry.js'; 16: import { getToolSearchOrReadInfo } from '../../utils/collapseReadSearch.js'; 17: import { enqueuePendingNotification } from '../../utils/messageQueueManager.js'; 18: import { getAgentTranscriptPath } from '../../utils/sessionStorage.js'; 19: import { evictTaskOutput, getTaskOutputPath, initTaskOutputAsSymlink } from '../../utils/task/diskOutput.js'; 20: import { PANEL_GRACE_MS, registerTask, updateTaskState } from '../../utils/task/framework.js'; 21: import { emitTaskProgress } from '../../utils/task/sdkProgress.js'; 22: import type { TaskState } from '../types.js'; 23: export type ToolActivity = { 24: toolName: string; 25: input: Record<string, unknown>; 26: activityDescription?: string; 27: isSearch?: boolean; 28: isRead?: boolean; 29: }; 30: export type AgentProgress = { 31: toolUseCount: number; 32: tokenCount: number; 33: lastActivity?: ToolActivity; 34: recentActivities?: ToolActivity[]; 35: summary?: string; 36: }; 37: const MAX_RECENT_ACTIVITIES = 5; 38: export type ProgressTracker = { 39: toolUseCount: number; 40: latestInputTokens: number; 41: cumulativeOutputTokens: number; 42: recentActivities: ToolActivity[]; 43: }; 44: export function createProgressTracker(): ProgressTracker { 45: return { 46: toolUseCount: 0, 47: latestInputTokens: 0, 48: cumulativeOutputTokens: 0, 49: recentActivities: [] 50: }; 51: } 52: export function getTokenCountFromTracker(tracker: ProgressTracker): number { 53: return tracker.latestInputTokens + tracker.cumulativeOutputTokens; 54: } 55: export type ActivityDescriptionResolver = (toolName: string, input: Record<string, unknown>) => string | undefined; 56: export function updateProgressFromMessage(tracker: ProgressTracker, message: Message, resolveActivityDescription?: ActivityDescriptionResolver, tools?: Tools): void { 57: if (message.type !== 'assistant') { 58: return; 59: } 60: const usage = message.message.usage; 61: tracker.latestInputTokens = usage.input_tokens + (usage.cache_creation_input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0); 62: tracker.cumulativeOutputTokens += usage.output_tokens; 63: for (const content of message.message.content) { 64: if (content.type === 'tool_use') { 65: tracker.toolUseCount++; 66: if (content.name !== SYNTHETIC_OUTPUT_TOOL_NAME) { 67: const input = content.input as Record<string, unknown>; 68: const classification = tools ? getToolSearchOrReadInfo(content.name, input, tools) : undefined; 69: tracker.recentActivities.push({ 70: toolName: content.name, 71: input, 72: activityDescription: resolveActivityDescription?.(content.name, input), 73: isSearch: classification?.isSearch, 74: isRead: classification?.isRead 75: }); 76: } 77: } 78: } 79: while (tracker.recentActivities.length > MAX_RECENT_ACTIVITIES) { 80: tracker.recentActivities.shift(); 81: } 82: } 83: export function getProgressUpdate(tracker: ProgressTracker): AgentProgress { 84: return { 85: toolUseCount: tracker.toolUseCount, 86: tokenCount: getTokenCountFromTracker(tracker), 87: lastActivity: tracker.recentActivities.length > 0 ? tracker.recentActivities[tracker.recentActivities.length - 1] : undefined, 88: recentActivities: [...tracker.recentActivities] 89: }; 90: } 91: export function createActivityDescriptionResolver(tools: Tools): ActivityDescriptionResolver { 92: return (toolName, input) => { 93: const tool = findToolByName(tools, toolName); 94: return tool?.getActivityDescription?.(input) ?? undefined; 95: }; 96: } 97: export type LocalAgentTaskState = TaskStateBase & { 98: type: 'local_agent'; 99: agentId: string; 100: prompt: string; 101: selectedAgent?: AgentDefinition; 102: agentType: string; 103: model?: string; 104: abortController?: AbortController; 105: unregisterCleanup?: () => void; 106: error?: string; 107: result?: AgentToolResult; 108: progress?: AgentProgress; 109: retrieved: boolean; 110: messages?: Message[]; 111: lastReportedToolCount: number; 112: lastReportedTokenCount: number; 113: isBackgrounded: boolean; 114: pendingMessages: string[]; 115: retain: boolean; 116: diskLoaded: boolean; 117: evictAfter?: number; 118: }; 119: export function isLocalAgentTask(task: unknown): task is LocalAgentTaskState { 120: return typeof task === 'object' && task !== null && 'type' in task && task.type === 'local_agent'; 121: } 122: export function isPanelAgentTask(t: unknown): t is LocalAgentTaskState { 123: return isLocalAgentTask(t) && t.agentType !== 'main-session'; 124: } 125: export function queuePendingMessage(taskId: string, msg: string, setAppState: (f: (prev: AppState) => AppState) => void): void { 126: updateTaskState<LocalAgentTaskState>(taskId, setAppState, task => ({ 127: ...task, 128: pendingMessages: [...task.pendingMessages, msg] 129: })); 130: } 131: export function appendMessageToLocalAgent(taskId: string, message: Message, setAppState: (f: (prev: AppState) => AppState) => void): void { 132: updateTaskState<LocalAgentTaskState>(taskId, setAppState, task => ({ 133: ...task, 134: messages: [...(task.messages ?? []), message] 135: })); 136: } 137: export function drainPendingMessages(taskId: string, getAppState: () => AppState, setAppState: (f: (prev: AppState) => AppState) => void): string[] { 138: const task = getAppState().tasks[taskId]; 139: if (!isLocalAgentTask(task) || task.pendingMessages.length === 0) { 140: return []; 141: } 142: const drained = task.pendingMessages; 143: updateTaskState<LocalAgentTaskState>(taskId, setAppState, t => ({ 144: ...t, 145: pendingMessages: [] 146: })); 147: return drained; 148: } 149: export function enqueueAgentNotification({ 150: taskId, 151: description, 152: status, 153: error, 154: setAppState, 155: finalMessage, 156: usage, 157: toolUseId, 158: worktreePath, 159: worktreeBranch 160: }: { 161: taskId: string; 162: description: string; 163: status: 'completed' | 'failed' | 'killed'; 164: error?: string; 165: setAppState: SetAppState; 166: finalMessage?: string; 167: usage?: { 168: totalTokens: number; 169: toolUses: number; 170: durationMs: number; 171: }; 172: toolUseId?: string; 173: worktreePath?: string; 174: worktreeBranch?: string; 175: }): void { 176: let shouldEnqueue = false; 177: updateTaskState<LocalAgentTaskState>(taskId, setAppState, task => { 178: if (task.notified) { 179: return task; 180: } 181: shouldEnqueue = true; 182: return { 183: ...task, 184: notified: true 185: }; 186: }); 187: if (!shouldEnqueue) { 188: return; 189: } 190: abortSpeculation(setAppState); 191: const summary = status === 'completed' ? `Agent "${description}" completed` : status === 'failed' ? `Agent "${description}" failed: ${error || 'Unknown error'}` : `Agent "${description}" was stopped`; 192: const outputPath = getTaskOutputPath(taskId); 193: const toolUseIdLine = toolUseId ? `\n<${TOOL_USE_ID_TAG}>${toolUseId}</${TOOL_USE_ID_TAG}>` : ''; 194: const resultSection = finalMessage ? `\n<result>${finalMessage}</result>` : ''; 195: const usageSection = usage ? `\n<usage><total_tokens>${usage.totalTokens}</total_tokens><tool_uses>${usage.toolUses}</tool_uses><duration_ms>${usage.durationMs}</duration_ms></usage>` : ''; 196: const worktreeSection = worktreePath ? `\n<${WORKTREE_TAG}><${WORKTREE_PATH_TAG}>${worktreePath}</${WORKTREE_PATH_TAG}>${worktreeBranch ? `<${WORKTREE_BRANCH_TAG}>${worktreeBranch}</${WORKTREE_BRANCH_TAG}>` : ''}</${WORKTREE_TAG}>` : ''; 197: const message = `<${TASK_NOTIFICATION_TAG}> 198: <${TASK_ID_TAG}>${taskId}</${TASK_ID_TAG}>${toolUseIdLine} 199: <${OUTPUT_FILE_TAG}>${outputPath}</${OUTPUT_FILE_TAG}> 200: <${STATUS_TAG}>${status}</${STATUS_TAG}> 201: <${SUMMARY_TAG}>${summary}</${SUMMARY_TAG}>${resultSection}${usageSection}${worktreeSection} 202: </${TASK_NOTIFICATION_TAG}>`; 203: enqueuePendingNotification({ 204: value: message, 205: mode: 'task-notification' 206: }); 207: } 208: export const LocalAgentTask: Task = { 209: name: 'LocalAgentTask', 210: type: 'local_agent', 211: async kill(taskId, setAppState) { 212: killAsyncAgent(taskId, setAppState); 213: } 214: }; 215: export function killAsyncAgent(taskId: string, setAppState: SetAppState): void { 216: let killed = false; 217: updateTaskState<LocalAgentTaskState>(taskId, setAppState, task => { 218: if (task.status !== 'running') { 219: return task; 220: } 221: killed = true; 222: task.abortController?.abort(); 223: task.unregisterCleanup?.(); 224: return { 225: ...task, 226: status: 'killed', 227: endTime: Date.now(), 228: evictAfter: task.retain ? undefined : Date.now() + PANEL_GRACE_MS, 229: abortController: undefined, 230: unregisterCleanup: undefined, 231: selectedAgent: undefined 232: }; 233: }); 234: if (killed) { 235: void evictTaskOutput(taskId); 236: } 237: } 238: export function killAllRunningAgentTasks(tasks: Record<string, TaskState>, setAppState: SetAppState): void { 239: for (const [taskId, task] of Object.entries(tasks)) { 240: if (task.type === 'local_agent' && task.status === 'running') { 241: killAsyncAgent(taskId, setAppState); 242: } 243: } 244: } 245: export function markAgentsNotified(taskId: string, setAppState: SetAppState): void { 246: updateTaskState<LocalAgentTaskState>(taskId, setAppState, task => { 247: if (task.notified) { 248: return task; 249: } 250: return { 251: ...task, 252: notified: true 253: }; 254: }); 255: } 256: export function updateAgentProgress(taskId: string, progress: AgentProgress, setAppState: SetAppState): void { 257: updateTaskState<LocalAgentTaskState>(taskId, setAppState, task => { 258: if (task.status !== 'running') { 259: return task; 260: } 261: const existingSummary = task.progress?.summary; 262: return { 263: ...task, 264: progress: existingSummary ? { 265: ...progress, 266: summary: existingSummary 267: } : progress 268: }; 269: }); 270: } 271: export function updateAgentSummary(taskId: string, summary: string, setAppState: SetAppState): void { 272: let captured: { 273: tokenCount: number; 274: toolUseCount: number; 275: startTime: number; 276: toolUseId: string | undefined; 277: } | null = null; 278: updateTaskState<LocalAgentTaskState>(taskId, setAppState, task => { 279: if (task.status !== 'running') { 280: return task; 281: } 282: captured = { 283: tokenCount: task.progress?.tokenCount ?? 0, 284: toolUseCount: task.progress?.toolUseCount ?? 0, 285: startTime: task.startTime, 286: toolUseId: task.toolUseId 287: }; 288: return { 289: ...task, 290: progress: { 291: ...task.progress, 292: toolUseCount: task.progress?.toolUseCount ?? 0, 293: tokenCount: task.progress?.tokenCount ?? 0, 294: summary 295: } 296: }; 297: }); 298: if (captured && getSdkAgentProgressSummariesEnabled()) { 299: const { 300: tokenCount, 301: toolUseCount, 302: startTime, 303: toolUseId 304: } = captured; 305: emitTaskProgress({ 306: taskId, 307: toolUseId, 308: description: summary, 309: startTime, 310: totalTokens: tokenCount, 311: toolUses: toolUseCount, 312: summary 313: }); 314: } 315: } 316: export function completeAgentTask(result: AgentToolResult, setAppState: SetAppState): void { 317: const taskId = result.agentId; 318: updateTaskState<LocalAgentTaskState>(taskId, setAppState, task => { 319: if (task.status !== 'running') { 320: return task; 321: } 322: task.unregisterCleanup?.(); 323: return { 324: ...task, 325: status: 'completed', 326: result, 327: endTime: Date.now(), 328: evictAfter: task.retain ? undefined : Date.now() + PANEL_GRACE_MS, 329: abortController: undefined, 330: unregisterCleanup: undefined, 331: selectedAgent: undefined 332: }; 333: }); 334: void evictTaskOutput(taskId); 335: } 336: export function failAgentTask(taskId: string, error: string, setAppState: SetAppState): void { 337: updateTaskState<LocalAgentTaskState>(taskId, setAppState, task => { 338: if (task.status !== 'running') { 339: return task; 340: } 341: task.unregisterCleanup?.(); 342: return { 343: ...task, 344: status: 'failed', 345: error, 346: endTime: Date.now(), 347: evictAfter: task.retain ? undefined : Date.now() + PANEL_GRACE_MS, 348: abortController: undefined, 349: unregisterCleanup: undefined, 350: selectedAgent: undefined 351: }; 352: }); 353: void evictTaskOutput(taskId); 354: } 355: export function registerAsyncAgent({ 356: agentId, 357: description, 358: prompt, 359: selectedAgent, 360: setAppState, 361: parentAbortController, 362: toolUseId 363: }: { 364: agentId: string; 365: description: string; 366: prompt: string; 367: selectedAgent: AgentDefinition; 368: setAppState: SetAppState; 369: parentAbortController?: AbortController; 370: toolUseId?: string; 371: }): LocalAgentTaskState { 372: void initTaskOutputAsSymlink(agentId, getAgentTranscriptPath(asAgentId(agentId))); 373: const abortController = parentAbortController ? createChildAbortController(parentAbortController) : createAbortController(); 374: const taskState: LocalAgentTaskState = { 375: ...createTaskStateBase(agentId, 'local_agent', description, toolUseId), 376: type: 'local_agent', 377: status: 'running', 378: agentId, 379: prompt, 380: selectedAgent, 381: agentType: selectedAgent.agentType ?? 'general-purpose', 382: abortController, 383: retrieved: false, 384: lastReportedToolCount: 0, 385: lastReportedTokenCount: 0, 386: isBackgrounded: true, 387: pendingMessages: [], 388: retain: false, 389: diskLoaded: false 390: }; 391: const unregisterCleanup = registerCleanup(async () => { 392: killAsyncAgent(agentId, setAppState); 393: }); 394: taskState.unregisterCleanup = unregisterCleanup; 395: registerTask(taskState, setAppState); 396: return taskState; 397: } 398: const backgroundSignalResolvers = new Map<string, () => void>(); 399: export function registerAgentForeground({ 400: agentId, 401: description, 402: prompt, 403: selectedAgent, 404: setAppState, 405: autoBackgroundMs, 406: toolUseId 407: }: { 408: agentId: string; 409: description: string; 410: prompt: string; 411: selectedAgent: AgentDefinition; 412: setAppState: SetAppState; 413: autoBackgroundMs?: number; 414: toolUseId?: string; 415: }): { 416: taskId: string; 417: backgroundSignal: Promise<void>; 418: cancelAutoBackground?: () => void; 419: } { 420: void initTaskOutputAsSymlink(agentId, getAgentTranscriptPath(asAgentId(agentId))); 421: const abortController = createAbortController(); 422: const unregisterCleanup = registerCleanup(async () => { 423: killAsyncAgent(agentId, setAppState); 424: }); 425: const taskState: LocalAgentTaskState = { 426: ...createTaskStateBase(agentId, 'local_agent', description, toolUseId), 427: type: 'local_agent', 428: status: 'running', 429: agentId, 430: prompt, 431: selectedAgent, 432: agentType: selectedAgent.agentType ?? 'general-purpose', 433: abortController, 434: unregisterCleanup, 435: retrieved: false, 436: lastReportedToolCount: 0, 437: lastReportedTokenCount: 0, 438: isBackgrounded: false, 439: pendingMessages: [], 440: retain: false, 441: diskLoaded: false 442: }; 443: let resolveBackgroundSignal: () => void; 444: const backgroundSignal = new Promise<void>(resolve => { 445: resolveBackgroundSignal = resolve; 446: }); 447: backgroundSignalResolvers.set(agentId, resolveBackgroundSignal!); 448: registerTask(taskState, setAppState); 449: let cancelAutoBackground: (() => void) | undefined; 450: if (autoBackgroundMs !== undefined && autoBackgroundMs > 0) { 451: const timer = setTimeout((setAppState, agentId) => { 452: setAppState(prev => { 453: const prevTask = prev.tasks[agentId]; 454: if (!isLocalAgentTask(prevTask) || prevTask.isBackgrounded) { 455: return prev; 456: } 457: return { 458: ...prev, 459: tasks: { 460: ...prev.tasks, 461: [agentId]: { 462: ...prevTask, 463: isBackgrounded: true 464: } 465: } 466: }; 467: }); 468: const resolver = backgroundSignalResolvers.get(agentId); 469: if (resolver) { 470: resolver(); 471: backgroundSignalResolvers.delete(agentId); 472: } 473: }, autoBackgroundMs, setAppState, agentId); 474: cancelAutoBackground = () => clearTimeout(timer); 475: } 476: return { 477: taskId: agentId, 478: backgroundSignal, 479: cancelAutoBackground 480: }; 481: } 482: export function backgroundAgentTask(taskId: string, getAppState: () => AppState, setAppState: SetAppState): boolean { 483: const state = getAppState(); 484: const task = state.tasks[taskId]; 485: if (!isLocalAgentTask(task) || task.isBackgrounded) { 486: return false; 487: } 488: setAppState(prev => { 489: const prevTask = prev.tasks[taskId]; 490: if (!isLocalAgentTask(prevTask)) { 491: return prev; 492: } 493: return { 494: ...prev, 495: tasks: { 496: ...prev.tasks, 497: [taskId]: { 498: ...prevTask, 499: isBackgrounded: true 500: } 501: } 502: }; 503: }); 504: const resolver = backgroundSignalResolvers.get(taskId); 505: if (resolver) { 506: resolver(); 507: backgroundSignalResolvers.delete(taskId); 508: } 509: return true; 510: } 511: export function unregisterAgentForeground(taskId: string, setAppState: SetAppState): void { 512: backgroundSignalResolvers.delete(taskId); 513: let cleanupFn: (() => void) | undefined; 514: setAppState(prev => { 515: const task = prev.tasks[taskId]; 516: if (!isLocalAgentTask(task) || task.isBackgrounded) { 517: return prev; 518: } 519: cleanupFn = task.unregisterCleanup; 520: const { 521: [taskId]: removed, 522: ...rest 523: } = prev.tasks; 524: return { 525: ...prev, 526: tasks: rest 527: }; 528: }); 529: cleanupFn?.(); 530: }

File: src/tasks/LocalShellTask/guards.ts

typescript 1: import type { TaskStateBase } from '../../Task.js' 2: import type { AgentId } from '../../types/ids.js' 3: import type { ShellCommand } from '../../utils/ShellCommand.js' 4: export type BashTaskKind = 'bash' | 'monitor' 5: export type LocalShellTaskState = TaskStateBase & { 6: type: 'local_bash' 7: command: string 8: result?: { 9: code: number 10: interrupted: boolean 11: } 12: completionStatusSentInAttachment: boolean 13: shellCommand: ShellCommand | null 14: unregisterCleanup?: () => void 15: cleanupTimeoutId?: NodeJS.Timeout 16: lastReportedTotalLines: number 17: isBackgrounded: boolean 18: agentId?: AgentId 19: kind?: BashTaskKind 20: } 21: export function isLocalShellTask(task: unknown): task is LocalShellTaskState { 22: return ( 23: typeof task === 'object' && 24: task !== null && 25: 'type' in task && 26: task.type === 'local_bash' 27: ) 28: }

File: src/tasks/LocalShellTask/killShellTasks.ts

typescript 1: import type { AppState } from '../../state/AppState.js' 2: import type { AgentId } from '../../types/ids.js' 3: import { logForDebugging } from '../../utils/debug.js' 4: import { logError } from '../../utils/log.js' 5: import { dequeueAllMatching } from '../../utils/messageQueueManager.js' 6: import { evictTaskOutput } from '../../utils/task/diskOutput.js' 7: import { updateTaskState } from '../../utils/task/framework.js' 8: import { isLocalShellTask } from './guards.js' 9: type SetAppStateFn = (updater: (prev: AppState) => AppState) => void 10: export function killTask(taskId: string, setAppState: SetAppStateFn): void { 11: updateTaskState(taskId, setAppState, task => { 12: if (task.status !== 'running' || !isLocalShellTask(task)) { 13: return task 14: } 15: try { 16: logForDebugging(`LocalShellTask ${taskId} kill requested`) 17: task.shellCommand?.kill() 18: task.shellCommand?.cleanup() 19: } catch (error) { 20: logError(error) 21: } 22: task.unregisterCleanup?.() 23: if (task.cleanupTimeoutId) { 24: clearTimeout(task.cleanupTimeoutId) 25: } 26: return { 27: ...task, 28: status: 'killed', 29: notified: true, 30: shellCommand: null, 31: unregisterCleanup: undefined, 32: cleanupTimeoutId: undefined, 33: endTime: Date.now(), 34: } 35: }) 36: void evictTaskOutput(taskId) 37: } 38: export function killShellTasksForAgent( 39: agentId: AgentId, 40: getAppState: () => AppState, 41: setAppState: SetAppStateFn, 42: ): void { 43: const tasks = getAppState().tasks ?? {} 44: for (const [taskId, task] of Object.entries(tasks)) { 45: if ( 46: isLocalShellTask(task) && 47: task.agentId === agentId && 48: task.status === 'running' 49: ) { 50: logForDebugging( 51: `killShellTasksForAgent: killing orphaned shell task ${taskId} (agent ${agentId} exiting)`, 52: ) 53: killTask(taskId, setAppState) 54: } 55: } 56: dequeueAllMatching(cmd => cmd.agentId === agentId) 57: }

File: src/tasks/LocalShellTask/LocalShellTask.tsx

typescript 1: import { feature } from 'bun:bundle'; 2: import { stat } from 'fs/promises'; 3: import { OUTPUT_FILE_TAG, STATUS_TAG, SUMMARY_TAG, TASK_ID_TAG, TASK_NOTIFICATION_TAG, TOOL_USE_ID_TAG } from '../../constants/xml.js'; 4: import { abortSpeculation } from '../../services/PromptSuggestion/speculation.js'; 5: import type { AppState } from '../../state/AppState.js'; 6: import type { LocalShellSpawnInput, SetAppState, Task, TaskContext, TaskHandle } from '../../Task.js'; 7: import { createTaskStateBase } from '../../Task.js'; 8: import type { AgentId } from '../../types/ids.js'; 9: import { registerCleanup } from '../../utils/cleanupRegistry.js'; 10: import { tailFile } from '../../utils/fsOperations.js'; 11: import { logError } from '../../utils/log.js'; 12: import { enqueuePendingNotification } from '../../utils/messageQueueManager.js'; 13: import type { ShellCommand } from '../../utils/ShellCommand.js'; 14: import { evictTaskOutput, getTaskOutputPath } from '../../utils/task/diskOutput.js'; 15: import { registerTask, updateTaskState } from '../../utils/task/framework.js'; 16: import { escapeXml } from '../../utils/xml.js'; 17: import { backgroundAgentTask, isLocalAgentTask } from '../LocalAgentTask/LocalAgentTask.js'; 18: import { isMainSessionTask } from '../LocalMainSessionTask.js'; 19: import { type BashTaskKind, isLocalShellTask, type LocalShellTaskState } from './guards.js'; 20: import { killTask } from './killShellTasks.js'; 21: export const BACKGROUND_BASH_SUMMARY_PREFIX = 'Background command '; 22: const STALL_CHECK_INTERVAL_MS = 5_000; 23: const STALL_THRESHOLD_MS = 45_000; 24: const STALL_TAIL_BYTES = 1024; 25: const PROMPT_PATTERNS = [/\(y\/n\)/i, 26: /\[y\/n\]/i, 27: /\(yes\/no\)/i, /\b(?:Do you|Would you|Shall I|Are you sure|Ready to)\b.*\? *$/i, 28: /Press (any key|Enter)/i, /Continue\?/i, /Overwrite\?/i]; 29: export function looksLikePrompt(tail: string): boolean { 30: const lastLine = tail.trimEnd().split('\n').pop() ?? ''; 31: return PROMPT_PATTERNS.some(p => p.test(lastLine)); 32: } 33: // Output-side analog of peekForStdinData (utils/process.ts): fire a one-shot 34: // notification if output stops growing and the tail looks like a prompt. 35: function startStallWatchdog(taskId: string, description: string, kind: BashTaskKind | undefined, toolUseId?: string, agentId?: AgentId): () => void { 36: if (kind === 'monitor') return () => {}; 37: const outputPath = getTaskOutputPath(taskId); 38: let lastSize = 0; 39: let lastGrowth = Date.now(); 40: let cancelled = false; 41: const timer = setInterval(() => { 42: void stat(outputPath).then(s => { 43: if (s.size > lastSize) { 44: lastSize = s.size; 45: lastGrowth = Date.now(); 46: return; 47: } 48: if (Date.now() - lastGrowth < STALL_THRESHOLD_MS) return; 49: void tailFile(outputPath, STALL_TAIL_BYTES).then(({ 50: content 51: }) => { 52: if (cancelled) return; 53: if (!looksLikePrompt(content)) { 54: lastGrowth = Date.now(); 55: return; 56: } 57: cancelled = true; 58: clearInterval(timer); 59: const toolUseIdLine = toolUseId ? `\n<${TOOL_USE_ID_TAG}>${toolUseId}</${TOOL_USE_ID_TAG}>` : ''; 60: const summary = `${BACKGROUND_BASH_SUMMARY_PREFIX}"${description}" appears to be waiting for interactive input`; 61: // No <status> tag — print.ts treats <status> as a terminal 62: // signal and an unknown value falls through to 'completed', 63: const message = `<${TASK_NOTIFICATION_TAG}> 64: <${TASK_ID_TAG}>${taskId}</${TASK_ID_TAG}>${toolUseIdLine} 65: <${OUTPUT_FILE_TAG}>${outputPath}</${OUTPUT_FILE_TAG}> 66: <${SUMMARY_TAG}>${escapeXml(summary)}</${SUMMARY_TAG}> 67: </${TASK_NOTIFICATION_TAG}> 68: Last output: 69: ${content.trimEnd()} 70: The command is likely blocked on an interactive prompt. Kill this task and re-run with piped input (e.g., \`echo y | command\`) or a non-interactive flag if one exists.`; 71: enqueuePendingNotification({ 72: value: message, 73: mode: 'task-notification', 74: priority: 'next', 75: agentId 76: }); 77: }, () => {}); 78: }, () => {} 79: ); 80: }, STALL_CHECK_INTERVAL_MS); 81: timer.unref(); 82: return () => { 83: cancelled = true; 84: clearInterval(timer); 85: }; 86: } 87: function enqueueShellNotification(taskId: string, description: string, status: 'completed' | 'failed' | 'killed', exitCode: number | undefined, setAppState: SetAppState, toolUseId?: string, kind: BashTaskKind = 'bash', agentId?: AgentId): void { 88: let shouldEnqueue = false; 89: updateTaskState(taskId, setAppState, task => { 90: if (task.notified) { 91: return task; 92: } 93: shouldEnqueue = true; 94: return { 95: ...task, 96: notified: true 97: }; 98: }); 99: if (!shouldEnqueue) { 100: return; 101: } 102: abortSpeculation(setAppState); 103: let summary: string; 104: if (feature('MONITOR_TOOL') && kind === 'monitor') { 105: switch (status) { 106: case 'completed': 107: summary = `Monitor "${description}" stream ended`; 108: break; 109: case 'failed': 110: summary = `Monitor "${description}" script failed${exitCode !== undefined ? ` (exit ${exitCode})` : ''}`; 111: break; 112: case 'killed': 113: summary = `Monitor "${description}" stopped`; 114: break; 115: } 116: } else { 117: switch (status) { 118: case 'completed': 119: summary = `${BACKGROUND_BASH_SUMMARY_PREFIX}"${description}" completed${exitCode !== undefined ? ` (exit code ${exitCode})` : ''}`; 120: break; 121: case 'failed': 122: summary = `${BACKGROUND_BASH_SUMMARY_PREFIX}"${description}" failed${exitCode !== undefined ? ` with exit code ${exitCode}` : ''}`; 123: break; 124: case 'killed': 125: summary = `${BACKGROUND_BASH_SUMMARY_PREFIX}"${description}" was stopped`; 126: break; 127: } 128: } 129: const outputPath = getTaskOutputPath(taskId); 130: const toolUseIdLine = toolUseId ? `\n<${TOOL_USE_ID_TAG}>${toolUseId}</${TOOL_USE_ID_TAG}>` : ''; 131: const message = `<${TASK_NOTIFICATION_TAG}> 132: <${TASK_ID_TAG}>${taskId}</${TASK_ID_TAG}>${toolUseIdLine} 133: <${OUTPUT_FILE_TAG}>${outputPath}</${OUTPUT_FILE_TAG}> 134: <${STATUS_TAG}>${status}</${STATUS_TAG}> 135: <${SUMMARY_TAG}>${escapeXml(summary)}</${SUMMARY_TAG}> 136: </${TASK_NOTIFICATION_TAG}>`; 137: enqueuePendingNotification({ 138: value: message, 139: mode: 'task-notification', 140: priority: feature('MONITOR_TOOL') ? 'next' : 'later', 141: agentId 142: }); 143: } 144: export const LocalShellTask: Task = { 145: name: 'LocalShellTask', 146: type: 'local_bash', 147: async kill(taskId, setAppState) { 148: killTask(taskId, setAppState); 149: } 150: }; 151: export async function spawnShellTask(input: LocalShellSpawnInput & { 152: shellCommand: ShellCommand; 153: }, context: TaskContext): Promise<TaskHandle> { 154: const { 155: command, 156: description, 157: shellCommand, 158: toolUseId, 159: agentId, 160: kind 161: } = input; 162: const { 163: setAppState 164: } = context; 165: const { 166: taskOutput 167: } = shellCommand; 168: const taskId = taskOutput.taskId; 169: const unregisterCleanup = registerCleanup(async () => { 170: killTask(taskId, setAppState); 171: }); 172: const taskState: LocalShellTaskState = { 173: ...createTaskStateBase(taskId, 'local_bash', description, toolUseId), 174: type: 'local_bash', 175: status: 'running', 176: command, 177: completionStatusSentInAttachment: false, 178: shellCommand, 179: unregisterCleanup, 180: lastReportedTotalLines: 0, 181: isBackgrounded: true, 182: agentId, 183: kind 184: }; 185: registerTask(taskState, setAppState); 186: shellCommand.background(taskId); 187: const cancelStallWatchdog = startStallWatchdog(taskId, description, kind, toolUseId, agentId); 188: void shellCommand.result.then(async result => { 189: cancelStallWatchdog(); 190: await flushAndCleanup(shellCommand); 191: let wasKilled = false; 192: updateTaskState<LocalShellTaskState>(taskId, setAppState, task => { 193: if (task.status === 'killed') { 194: wasKilled = true; 195: return task; 196: } 197: return { 198: ...task, 199: status: result.code === 0 ? 'completed' : 'failed', 200: result: { 201: code: result.code, 202: interrupted: result.interrupted 203: }, 204: shellCommand: null, 205: unregisterCleanup: undefined, 206: endTime: Date.now() 207: }; 208: }); 209: enqueueShellNotification(taskId, description, wasKilled ? 'killed' : result.code === 0 ? 'completed' : 'failed', result.code, setAppState, toolUseId, kind, agentId); 210: void evictTaskOutput(taskId); 211: }); 212: return { 213: taskId, 214: cleanup: () => { 215: unregisterCleanup(); 216: } 217: }; 218: } 219: export function registerForeground(input: LocalShellSpawnInput & { 220: shellCommand: ShellCommand; 221: }, setAppState: SetAppState, toolUseId?: string): string { 222: const { 223: command, 224: description, 225: shellCommand, 226: agentId 227: } = input; 228: const taskId = shellCommand.taskOutput.taskId; 229: const unregisterCleanup = registerCleanup(async () => { 230: killTask(taskId, setAppState); 231: }); 232: const taskState: LocalShellTaskState = { 233: ...createTaskStateBase(taskId, 'local_bash', description, toolUseId), 234: type: 'local_bash', 235: status: 'running', 236: command, 237: completionStatusSentInAttachment: false, 238: shellCommand, 239: unregisterCleanup, 240: lastReportedTotalLines: 0, 241: isBackgrounded: false, 242: agentId 243: }; 244: registerTask(taskState, setAppState); 245: return taskId; 246: } 247: function backgroundTask(taskId: string, getAppState: () => AppState, setAppState: SetAppState): boolean { 248: const state = getAppState(); 249: const task = state.tasks[taskId]; 250: if (!isLocalShellTask(task) || task.isBackgrounded || !task.shellCommand) { 251: return false; 252: } 253: const shellCommand = task.shellCommand; 254: const description = task.description; 255: const { 256: toolUseId, 257: kind, 258: agentId 259: } = task; 260: if (!shellCommand.background(taskId)) { 261: return false; 262: } 263: setAppState(prev => { 264: const prevTask = prev.tasks[taskId]; 265: if (!isLocalShellTask(prevTask) || prevTask.isBackgrounded) { 266: return prev; 267: } 268: return { 269: ...prev, 270: tasks: { 271: ...prev.tasks, 272: [taskId]: { 273: ...prevTask, 274: isBackgrounded: true 275: } 276: } 277: }; 278: }); 279: const cancelStallWatchdog = startStallWatchdog(taskId, description, kind, toolUseId, agentId); 280: void shellCommand.result.then(async result => { 281: cancelStallWatchdog(); 282: await flushAndCleanup(shellCommand); 283: let wasKilled = false; 284: let cleanupFn: (() => void) | undefined; 285: updateTaskState<LocalShellTaskState>(taskId, setAppState, t => { 286: if (t.status === 'killed') { 287: wasKilled = true; 288: return t; 289: } 290: cleanupFn = t.unregisterCleanup; 291: return { 292: ...t, 293: status: result.code === 0 ? 'completed' : 'failed', 294: result: { 295: code: result.code, 296: interrupted: result.interrupted 297: }, 298: shellCommand: null, 299: unregisterCleanup: undefined, 300: endTime: Date.now() 301: }; 302: }); 303: cleanupFn?.(); 304: if (wasKilled) { 305: enqueueShellNotification(taskId, description, 'killed', result.code, setAppState, toolUseId, kind, agentId); 306: } else { 307: const finalStatus = result.code === 0 ? 'completed' : 'failed'; 308: enqueueShellNotification(taskId, description, finalStatus, result.code, setAppState, toolUseId, kind, agentId); 309: } 310: void evictTaskOutput(taskId); 311: }); 312: return true; 313: } 314: export function hasForegroundTasks(state: AppState): boolean { 315: return Object.values(state.tasks).some(task => { 316: if (isLocalShellTask(task) && !task.isBackgrounded && task.shellCommand) { 317: return true; 318: } 319: if (isLocalAgentTask(task) && !task.isBackgrounded && !isMainSessionTask(task)) { 320: return true; 321: } 322: return false; 323: }); 324: } 325: export function backgroundAll(getAppState: () => AppState, setAppState: SetAppState): void { 326: const state = getAppState(); 327: const foregroundBashTaskIds = Object.keys(state.tasks).filter(id => { 328: const task = state.tasks[id]; 329: return isLocalShellTask(task) && !task.isBackgrounded && task.shellCommand; 330: }); 331: for (const taskId of foregroundBashTaskIds) { 332: backgroundTask(taskId, getAppState, setAppState); 333: } 334: const foregroundAgentTaskIds = Object.keys(state.tasks).filter(id => { 335: const task = state.tasks[id]; 336: return isLocalAgentTask(task) && !task.isBackgrounded; 337: }); 338: for (const taskId of foregroundAgentTaskIds) { 339: backgroundAgentTask(taskId, getAppState, setAppState); 340: } 341: } 342: export function backgroundExistingForegroundTask(taskId: string, shellCommand: ShellCommand, description: string, setAppState: SetAppState, toolUseId?: string): boolean { 343: if (!shellCommand.background(taskId)) { 344: return false; 345: } 346: let agentId: AgentId | undefined; 347: setAppState(prev => { 348: const prevTask = prev.tasks[taskId]; 349: if (!isLocalShellTask(prevTask) || prevTask.isBackgrounded) { 350: return prev; 351: } 352: agentId = prevTask.agentId; 353: return { 354: ...prev, 355: tasks: { 356: ...prev.tasks, 357: [taskId]: { 358: ...prevTask, 359: isBackgrounded: true 360: } 361: } 362: }; 363: }); 364: const cancelStallWatchdog = startStallWatchdog(taskId, description, undefined, toolUseId, agentId); 365: void shellCommand.result.then(async result => { 366: cancelStallWatchdog(); 367: await flushAndCleanup(shellCommand); 368: let wasKilled = false; 369: let cleanupFn: (() => void) | undefined; 370: updateTaskState<LocalShellTaskState>(taskId, setAppState, t => { 371: if (t.status === 'killed') { 372: wasKilled = true; 373: return t; 374: } 375: cleanupFn = t.unregisterCleanup; 376: return { 377: ...t, 378: status: result.code === 0 ? 'completed' : 'failed', 379: result: { 380: code: result.code, 381: interrupted: result.interrupted 382: }, 383: shellCommand: null, 384: unregisterCleanup: undefined, 385: endTime: Date.now() 386: }; 387: }); 388: cleanupFn?.(); 389: const finalStatus = wasKilled ? 'killed' : result.code === 0 ? 'completed' : 'failed'; 390: enqueueShellNotification(taskId, description, finalStatus, result.code, setAppState, toolUseId, undefined, agentId); 391: void evictTaskOutput(taskId); 392: }); 393: return true; 394: } 395: export function markTaskNotified(taskId: string, setAppState: SetAppState): void { 396: updateTaskState(taskId, setAppState, t => t.notified ? t : { 397: ...t, 398: notified: true 399: }); 400: } 401: export function unregisterForeground(taskId: string, setAppState: SetAppState): void { 402: let cleanupFn: (() => void) | undefined; 403: setAppState(prev => { 404: const task = prev.tasks[taskId]; 405: if (!isLocalShellTask(task) || task.isBackgrounded) { 406: return prev; 407: } 408: cleanupFn = task.unregisterCleanup; 409: const { 410: [taskId]: removed, 411: ...rest 412: } = prev.tasks; 413: return { 414: ...prev, 415: tasks: rest 416: }; 417: }); 418: cleanupFn?.(); 419: } 420: async function flushAndCleanup(shellCommand: ShellCommand): Promise<void> { 421: try { 422: await shellCommand.taskOutput.flush(); 423: shellCommand.cleanup(); 424: } catch (error) { 425: logError(error); 426: } 427: }

File: src/tasks/RemoteAgentTask/RemoteAgentTask.tsx

typescript 1: import type { ToolUseBlock } from '@anthropic-ai/sdk/resources'; 2: import { getRemoteSessionUrl } from '../../constants/product.js'; 3: import { OUTPUT_FILE_TAG, REMOTE_REVIEW_PROGRESS_TAG, REMOTE_REVIEW_TAG, STATUS_TAG, SUMMARY_TAG, TASK_ID_TAG, TASK_NOTIFICATION_TAG, TASK_TYPE_TAG, TOOL_USE_ID_TAG, ULTRAPLAN_TAG } from '../../constants/xml.js'; 4: import type { SDKAssistantMessage, SDKMessage } from '../../entrypoints/agentSdkTypes.js'; 5: import type { SetAppState, Task, TaskContext, TaskStateBase } from '../../Task.js'; 6: import { createTaskStateBase, generateTaskId } from '../../Task.js'; 7: import { TodoWriteTool } from '../../tools/TodoWriteTool/TodoWriteTool.js'; 8: import { type BackgroundRemoteSessionPrecondition, checkBackgroundRemoteSessionEligibility } from '../../utils/background/remote/remoteSession.js'; 9: import { logForDebugging } from '../../utils/debug.js'; 10: import { logError } from '../../utils/log.js'; 11: import { enqueuePendingNotification } from '../../utils/messageQueueManager.js'; 12: import { extractTag, extractTextContent } from '../../utils/messages.js'; 13: import { emitTaskTerminatedSdk } from '../../utils/sdkEventQueue.js'; 14: import { deleteRemoteAgentMetadata, listRemoteAgentMetadata, type RemoteAgentMetadata, writeRemoteAgentMetadata } from '../../utils/sessionStorage.js'; 15: import { jsonStringify } from '../../utils/slowOperations.js'; 16: import { appendTaskOutput, evictTaskOutput, getTaskOutputPath, initTaskOutput } from '../../utils/task/diskOutput.js'; 17: import { registerTask, updateTaskState } from '../../utils/task/framework.js'; 18: import { fetchSession } from '../../utils/teleport/api.js'; 19: import { archiveRemoteSession, pollRemoteSessionEvents } from '../../utils/teleport.js'; 20: import type { TodoList } from '../../utils/todo/types.js'; 21: import type { UltraplanPhase } from '../../utils/ultraplan/ccrSession.js'; 22: export type RemoteAgentTaskState = TaskStateBase & { 23: type: 'remote_agent'; 24: remoteTaskType: RemoteTaskType; 25: remoteTaskMetadata?: RemoteTaskMetadata; 26: sessionId: string; 27: command: string; 28: title: string; 29: todoList: TodoList; 30: log: SDKMessage[]; 31: isLongRunning?: boolean; 32: pollStartedAt: number; 33: isRemoteReview?: boolean; 34: reviewProgress?: { 35: stage?: 'finding' | 'verifying' | 'synthesizing'; 36: bugsFound: number; 37: bugsVerified: number; 38: bugsRefuted: number; 39: }; 40: isUltraplan?: boolean; 41: ultraplanPhase?: Exclude<UltraplanPhase, 'running'>; 42: }; 43: const REMOTE_TASK_TYPES = ['remote-agent', 'ultraplan', 'ultrareview', 'autofix-pr', 'background-pr'] as const; 44: export type RemoteTaskType = (typeof REMOTE_TASK_TYPES)[number]; 45: function isRemoteTaskType(v: string | undefined): v is RemoteTaskType { 46: return (REMOTE_TASK_TYPES as readonly string[]).includes(v ?? ''); 47: } 48: export type AutofixPrRemoteTaskMetadata = { 49: owner: string; 50: repo: string; 51: prNumber: number; 52: }; 53: export type RemoteTaskMetadata = AutofixPrRemoteTaskMetadata; 54: /** 55: * Called on every poll tick for tasks with a matching remoteTaskType. Return a 56: * non-null string to complete the task (string becomes the notification text), 57: * or null to keep polling. Checkers that hit external APIs should self-throttle. 58: */ 59: export type RemoteTaskCompletionChecker = (remoteTaskMetadata: RemoteTaskMetadata | undefined) => Promise<string | null>; 60: const completionCheckers = new Map<RemoteTaskType, RemoteTaskCompletionChecker>(); 61: /** 62: * Register a completion checker for a remote task type. Invoked on every poll 63: * tick; survives --resume via the sidecar's remoteTaskType + remoteTaskMetadata. 64: */ 65: export function registerCompletionChecker(remoteTaskType: RemoteTaskType, checker: RemoteTaskCompletionChecker): void { 66: completionCheckers.set(remoteTaskType, checker); 67: } 68: async function persistRemoteAgentMetadata(meta: RemoteAgentMetadata): Promise<void> { 69: try { 70: await writeRemoteAgentMetadata(meta.taskId, meta); 71: } catch (e) { 72: logForDebugging(`persistRemoteAgentMetadata failed: ${String(e)}`); 73: } 74: } 75: async function removeRemoteAgentMetadata(taskId: string): Promise<void> { 76: try { 77: await deleteRemoteAgentMetadata(taskId); 78: } catch (e) { 79: logForDebugging(`removeRemoteAgentMetadata failed: ${String(e)}`); 80: } 81: } 82: export type RemoteAgentPreconditionResult = { 83: eligible: true; 84: } | { 85: eligible: false; 86: errors: BackgroundRemoteSessionPrecondition[]; 87: }; 88: export async function checkRemoteAgentEligibility({ 89: skipBundle = false 90: }: { 91: skipBundle?: boolean; 92: } = {}): Promise<RemoteAgentPreconditionResult> { 93: const errors = await checkBackgroundRemoteSessionEligibility({ 94: skipBundle 95: }); 96: if (errors.length > 0) { 97: return { 98: eligible: false, 99: errors 100: }; 101: } 102: return { 103: eligible: true 104: }; 105: } 106: export function formatPreconditionError(error: BackgroundRemoteSessionPrecondition): string { 107: switch (error.type) { 108: case 'not_logged_in': 109: return 'Please run /login and sign in with your Claude.ai account (not Console).'; 110: case 'no_remote_environment': 111: return 'No cloud environment available. Set one up at https://claude.ai/code/onboarding?magic=env-setup'; 112: case 'not_in_git_repo': 113: return 'Background tasks require a git repository. Initialize git or run from a git repository.'; 114: case 'no_git_remote': 115: return 'Background tasks require a GitHub remote. Add one with `git remote add origin REPO_URL`.'; 116: case 'github_app_not_installed': 117: return 'The Claude GitHub app must be installed on this repository first.\nhttps://github.com/apps/claude/installations/new'; 118: case 'policy_blocked': 119: return "Remote sessions are disabled by your organization's policy. Contact your organization admin to enable them."; 120: } 121: } 122: function enqueueRemoteNotification(taskId: string, title: string, status: 'completed' | 'failed' | 'killed', setAppState: SetAppState, toolUseId?: string): void { 123: if (!markTaskNotified(taskId, setAppState)) return; 124: const statusText = status === 'completed' ? 'completed successfully' : status === 'failed' ? 'failed' : 'was stopped'; 125: const toolUseIdLine = toolUseId ? `\n<${TOOL_USE_ID_TAG}>${toolUseId}</${TOOL_USE_ID_TAG}>` : ''; 126: const outputPath = getTaskOutputPath(taskId); 127: const message = `<${TASK_NOTIFICATION_TAG}> 128: <${TASK_ID_TAG}>${taskId}</${TASK_ID_TAG}>${toolUseIdLine} 129: <${TASK_TYPE_TAG}>remote_agent</${TASK_TYPE_TAG}> 130: <${OUTPUT_FILE_TAG}>${outputPath}</${OUTPUT_FILE_TAG}> 131: <${STATUS_TAG}>${status}</${STATUS_TAG}> 132: <${SUMMARY_TAG}>Remote task "${title}" ${statusText}</${SUMMARY_TAG}> 133: </${TASK_NOTIFICATION_TAG}>`; 134: enqueuePendingNotification({ 135: value: message, 136: mode: 'task-notification' 137: }); 138: } 139: function markTaskNotified(taskId: string, setAppState: SetAppState): boolean { 140: let shouldEnqueue = false; 141: updateTaskState(taskId, setAppState, task => { 142: if (task.notified) { 143: return task; 144: } 145: shouldEnqueue = true; 146: return { 147: ...task, 148: notified: true 149: }; 150: }); 151: return shouldEnqueue; 152: } 153: export function extractPlanFromLog(log: SDKMessage[]): string | null { 154: for (let i = log.length - 1; i >= 0; i--) { 155: const msg = log[i]; 156: if (msg?.type !== 'assistant') continue; 157: const fullText = extractTextContent(msg.message.content, '\n'); 158: const plan = extractTag(fullText, ULTRAPLAN_TAG); 159: if (plan?.trim()) return plan.trim(); 160: } 161: return null; 162: } 163: export function enqueueUltraplanFailureNotification(taskId: string, sessionId: string, reason: string, setAppState: SetAppState): void { 164: if (!markTaskNotified(taskId, setAppState)) return; 165: const sessionUrl = getRemoteTaskSessionUrl(sessionId); 166: const message = `<${TASK_NOTIFICATION_TAG}> 167: <${TASK_ID_TAG}>${taskId}</${TASK_ID_TAG}> 168: <${TASK_TYPE_TAG}>remote_agent</${TASK_TYPE_TAG}> 169: <${STATUS_TAG}>failed</${STATUS_TAG}> 170: <${SUMMARY_TAG}>Ultraplan failed: ${reason}</${SUMMARY_TAG}> 171: </${TASK_NOTIFICATION_TAG}> 172: The remote Ultraplan session did not produce a plan (${reason}). Inspect the session at ${sessionUrl} and tell the user to retry locally with plan mode.`; 173: enqueuePendingNotification({ 174: value: message, 175: mode: 'task-notification' 176: }); 177: } 178: function extractReviewFromLog(log: SDKMessage[]): string | null { 179: for (let i = log.length - 1; i >= 0; i--) { 180: const msg = log[i]; 181: if (msg?.type === 'system' && (msg.subtype === 'hook_progress' || msg.subtype === 'hook_response')) { 182: const tagged = extractTag(msg.stdout, REMOTE_REVIEW_TAG); 183: if (tagged?.trim()) return tagged.trim(); 184: } 185: } 186: for (let i = log.length - 1; i >= 0; i--) { 187: const msg = log[i]; 188: if (msg?.type !== 'assistant') continue; 189: const fullText = extractTextContent(msg.message.content, '\n'); 190: const tagged = extractTag(fullText, REMOTE_REVIEW_TAG); 191: if (tagged?.trim()) return tagged.trim(); 192: } 193: const hookStdout = log.filter(msg => msg.type === 'system' && (msg.subtype === 'hook_progress' || msg.subtype === 'hook_response')).map(msg => msg.stdout).join(''); 194: const hookTagged = extractTag(hookStdout, REMOTE_REVIEW_TAG); 195: if (hookTagged?.trim()) return hookTagged.trim(); 196: // Fallback: concatenate all assistant text in chronological order. 197: const allText = log.filter((msg): msg is SDKAssistantMessage => msg.type === 'assistant').map(msg => extractTextContent(msg.message.content, '\n')).join('\n').trim(); 198: return allText || null; 199: } 200: function extractReviewTagFromLog(log: SDKMessage[]): string | null { 201: for (let i = log.length - 1; i >= 0; i--) { 202: const msg = log[i]; 203: if (msg?.type === 'system' && (msg.subtype === 'hook_progress' || msg.subtype === 'hook_response')) { 204: const tagged = extractTag(msg.stdout, REMOTE_REVIEW_TAG); 205: if (tagged?.trim()) return tagged.trim(); 206: } 207: } 208: for (let i = log.length - 1; i >= 0; i--) { 209: const msg = log[i]; 210: if (msg?.type !== 'assistant') continue; 211: const fullText = extractTextContent(msg.message.content, '\n'); 212: const tagged = extractTag(fullText, REMOTE_REVIEW_TAG); 213: if (tagged?.trim()) return tagged.trim(); 214: } 215: const hookStdout = log.filter(msg => msg.type === 'system' && (msg.subtype === 'hook_progress' || msg.subtype === 'hook_response')).map(msg => msg.stdout).join(''); 216: const hookTagged = extractTag(hookStdout, REMOTE_REVIEW_TAG); 217: if (hookTagged?.trim()) return hookTagged.trim(); 218: return null; 219: } 220: /** 221: * Enqueue a remote-review completion notification. Injects the review text 222: * directly into the message queue so the local model receives it on the next 223: * turn — no file indirection, no mode change. Session is kept alive so the 224: * claude.ai URL stays a durable record the user can revisit; TTL handles cleanup. 225: */ 226: function enqueueRemoteReviewNotification(taskId: string, reviewContent: string, setAppState: SetAppState): void { 227: if (!markTaskNotified(taskId, setAppState)) return; 228: const message = `<${TASK_NOTIFICATION_TAG}> 229: <${TASK_ID_TAG}>${taskId}</${TASK_ID_TAG}> 230: <${TASK_TYPE_TAG}>remote_agent</${TASK_TYPE_TAG}> 231: <${STATUS_TAG}>completed</${STATUS_TAG}> 232: <${SUMMARY_TAG}>Remote review completed</${SUMMARY_TAG}> 233: </${TASK_NOTIFICATION_TAG}> 234: The remote review produced the following findings: 235: ${reviewContent}`; 236: enqueuePendingNotification({ 237: value: message, 238: mode: 'task-notification' 239: }); 240: } 241: function enqueueRemoteReviewFailureNotification(taskId: string, reason: string, setAppState: SetAppState): void { 242: if (!markTaskNotified(taskId, setAppState)) return; 243: const message = `<${TASK_NOTIFICATION_TAG}> 244: <${TASK_ID_TAG}>${taskId}</${TASK_ID_TAG}> 245: <${TASK_TYPE_TAG}>remote_agent</${TASK_TYPE_TAG}> 246: <${STATUS_TAG}>failed</${STATUS_TAG}> 247: <${SUMMARY_TAG}>Remote review failed: ${reason}</${SUMMARY_TAG}> 248: </${TASK_NOTIFICATION_TAG}> 249: Remote review did not produce output (${reason}). Tell the user to retry /ultrareview, or use /review for a local review instead.`; 250: enqueuePendingNotification({ 251: value: message, 252: mode: 'task-notification' 253: }); 254: } 255: function extractTodoListFromLog(log: SDKMessage[]): TodoList { 256: const todoListMessage = log.findLast((msg): msg is SDKAssistantMessage => msg.type === 'assistant' && msg.message.content.some(block => block.type === 'tool_use' && block.name === TodoWriteTool.name)); 257: if (!todoListMessage) { 258: return []; 259: } 260: const input = todoListMessage.message.content.find((block): block is ToolUseBlock => block.type === 'tool_use' && block.name === TodoWriteTool.name)?.input; 261: if (!input) { 262: return []; 263: } 264: const parsedInput = TodoWriteTool.inputSchema.safeParse(input); 265: if (!parsedInput.success) { 266: return []; 267: } 268: return parsedInput.data.todos; 269: } 270: export function registerRemoteAgentTask(options: { 271: remoteTaskType: RemoteTaskType; 272: session: { 273: id: string; 274: title: string; 275: }; 276: command: string; 277: context: TaskContext; 278: toolUseId?: string; 279: isRemoteReview?: boolean; 280: isUltraplan?: boolean; 281: isLongRunning?: boolean; 282: remoteTaskMetadata?: RemoteTaskMetadata; 283: }): { 284: taskId: string; 285: sessionId: string; 286: cleanup: () => void; 287: } { 288: const { 289: remoteTaskType, 290: session, 291: command, 292: context, 293: toolUseId, 294: isRemoteReview, 295: isUltraplan, 296: isLongRunning, 297: remoteTaskMetadata 298: } = options; 299: const taskId = generateTaskId('remote_agent'); 300: void initTaskOutput(taskId); 301: const taskState: RemoteAgentTaskState = { 302: ...createTaskStateBase(taskId, 'remote_agent', session.title, toolUseId), 303: type: 'remote_agent', 304: remoteTaskType, 305: status: 'running', 306: sessionId: session.id, 307: command, 308: title: session.title, 309: todoList: [], 310: log: [], 311: isRemoteReview, 312: isUltraplan, 313: isLongRunning, 314: pollStartedAt: Date.now(), 315: remoteTaskMetadata 316: }; 317: registerTask(taskState, context.setAppState); 318: void persistRemoteAgentMetadata({ 319: taskId, 320: remoteTaskType, 321: sessionId: session.id, 322: title: session.title, 323: command, 324: spawnedAt: Date.now(), 325: toolUseId, 326: isUltraplan, 327: isRemoteReview, 328: isLongRunning, 329: remoteTaskMetadata 330: }); 331: const stopPolling = startRemoteSessionPolling(taskId, context); 332: return { 333: taskId, 334: sessionId: session.id, 335: cleanup: stopPolling 336: }; 337: } 338: export async function restoreRemoteAgentTasks(context: TaskContext): Promise<void> { 339: try { 340: await restoreRemoteAgentTasksImpl(context); 341: } catch (e) { 342: logForDebugging(`restoreRemoteAgentTasks failed: ${String(e)}`); 343: } 344: } 345: async function restoreRemoteAgentTasksImpl(context: TaskContext): Promise<void> { 346: const persisted = await listRemoteAgentMetadata(); 347: if (persisted.length === 0) return; 348: for (const meta of persisted) { 349: let remoteStatus: string; 350: try { 351: const session = await fetchSession(meta.sessionId); 352: remoteStatus = session.session_status; 353: } catch (e) { 354: if (e instanceof Error && e.message.startsWith('Session not found:')) { 355: logForDebugging(`restoreRemoteAgentTasks: dropping ${meta.taskId} (404: ${String(e)})`); 356: void removeRemoteAgentMetadata(meta.taskId); 357: } else { 358: logForDebugging(`restoreRemoteAgentTasks: skipping ${meta.taskId} (recoverable: ${String(e)})`); 359: } 360: continue; 361: } 362: if (remoteStatus === 'archived') { 363: void removeRemoteAgentMetadata(meta.taskId); 364: continue; 365: } 366: const taskState: RemoteAgentTaskState = { 367: ...createTaskStateBase(meta.taskId, 'remote_agent', meta.title, meta.toolUseId), 368: type: 'remote_agent', 369: remoteTaskType: isRemoteTaskType(meta.remoteTaskType) ? meta.remoteTaskType : 'remote-agent', 370: status: 'running', 371: sessionId: meta.sessionId, 372: command: meta.command, 373: title: meta.title, 374: todoList: [], 375: log: [], 376: isRemoteReview: meta.isRemoteReview, 377: isUltraplan: meta.isUltraplan, 378: isLongRunning: meta.isLongRunning, 379: startTime: meta.spawnedAt, 380: pollStartedAt: Date.now(), 381: remoteTaskMetadata: meta.remoteTaskMetadata as RemoteTaskMetadata | undefined 382: }; 383: registerTask(taskState, context.setAppState); 384: void initTaskOutput(meta.taskId); 385: startRemoteSessionPolling(meta.taskId, context); 386: } 387: } 388: function startRemoteSessionPolling(taskId: string, context: TaskContext): () => void { 389: let isRunning = true; 390: const POLL_INTERVAL_MS = 1000; 391: const REMOTE_REVIEW_TIMEOUT_MS = 30 * 60 * 1000; 392: const STABLE_IDLE_POLLS = 5; 393: let consecutiveIdlePolls = 0; 394: let lastEventId: string | null = null; 395: let accumulatedLog: SDKMessage[] = []; 396: let cachedReviewContent: string | null = null; 397: const poll = async (): Promise<void> => { 398: if (!isRunning) return; 399: try { 400: const appState = context.getAppState(); 401: const task = appState.tasks?.[taskId] as RemoteAgentTaskState | undefined; 402: if (!task || task.status !== 'running') { 403: return; 404: } 405: const response = await pollRemoteSessionEvents(task.sessionId, lastEventId); 406: lastEventId = response.lastEventId; 407: const logGrew = response.newEvents.length > 0; 408: if (logGrew) { 409: accumulatedLog = [...accumulatedLog, ...response.newEvents]; 410: const deltaText = response.newEvents.map(msg => { 411: if (msg.type === 'assistant') { 412: return msg.message.content.filter(block => block.type === 'text').map(block => 'text' in block ? block.text : '').join('\n'); 413: } 414: return jsonStringify(msg); 415: }).join('\n'); 416: if (deltaText) { 417: appendTaskOutput(taskId, deltaText + '\n'); 418: } 419: } 420: if (response.sessionStatus === 'archived') { 421: updateTaskState<RemoteAgentTaskState>(taskId, context.setAppState, t => t.status === 'running' ? { 422: ...t, 423: status: 'completed', 424: endTime: Date.now() 425: } : t); 426: enqueueRemoteNotification(taskId, task.title, 'completed', context.setAppState, task.toolUseId); 427: void evictTaskOutput(taskId); 428: void removeRemoteAgentMetadata(taskId); 429: return; 430: } 431: const checker = completionCheckers.get(task.remoteTaskType); 432: if (checker) { 433: const completionResult = await checker(task.remoteTaskMetadata); 434: if (completionResult !== null) { 435: updateTaskState<RemoteAgentTaskState>(taskId, context.setAppState, t => t.status === 'running' ? { 436: ...t, 437: status: 'completed', 438: endTime: Date.now() 439: } : t); 440: enqueueRemoteNotification(taskId, completionResult, 'completed', context.setAppState, task.toolUseId); 441: void evictTaskOutput(taskId); 442: void removeRemoteAgentMetadata(taskId); 443: return; 444: } 445: } 446: const result = task.isUltraplan || task.isLongRunning ? undefined : accumulatedLog.findLast(msg => msg.type === 'result'); 447: if (task.isRemoteReview && logGrew && cachedReviewContent === null) { 448: cachedReviewContent = extractReviewTagFromLog(response.newEvents); 449: } 450: let newProgress: RemoteAgentTaskState['reviewProgress']; 451: if (task.isRemoteReview && logGrew) { 452: const open = `<${REMOTE_REVIEW_PROGRESS_TAG}>`; 453: const close = `</${REMOTE_REVIEW_PROGRESS_TAG}>`; 454: for (const ev of response.newEvents) { 455: if (ev.type === 'system' && (ev.subtype === 'hook_progress' || ev.subtype === 'hook_response')) { 456: const s = ev.stdout; 457: const closeAt = s.lastIndexOf(close); 458: const openAt = closeAt === -1 ? -1 : s.lastIndexOf(open, closeAt); 459: if (openAt !== -1 && closeAt > openAt) { 460: try { 461: const p = JSON.parse(s.slice(openAt + open.length, closeAt)) as { 462: stage?: 'finding' | 'verifying' | 'synthesizing'; 463: bugs_found?: number; 464: bugs_verified?: number; 465: bugs_refuted?: number; 466: }; 467: newProgress = { 468: stage: p.stage, 469: bugsFound: p.bugs_found ?? 0, 470: bugsVerified: p.bugs_verified ?? 0, 471: bugsRefuted: p.bugs_refuted ?? 0 472: }; 473: } catch { 474: } 475: } 476: } 477: } 478: } 479: const hasAnyOutput = accumulatedLog.some(msg => msg.type === 'assistant' || task.isRemoteReview && msg.type === 'system' && (msg.subtype === 'hook_progress' || msg.subtype === 'hook_response')); 480: if (response.sessionStatus === 'idle' && !logGrew && hasAnyOutput) { 481: consecutiveIdlePolls++; 482: } else { 483: consecutiveIdlePolls = 0; 484: } 485: const stableIdle = consecutiveIdlePolls >= STABLE_IDLE_POLLS; 486: const hasSessionStartHook = accumulatedLog.some(m => m.type === 'system' && (m.subtype === 'hook_started' || m.subtype === 'hook_progress' || m.subtype === 'hook_response') && (m as { 487: hook_event?: string; 488: }).hook_event === 'SessionStart'); 489: const hasAssistantEvents = accumulatedLog.some(m => m.type === 'assistant'); 490: const sessionDone = task.isRemoteReview && (cachedReviewContent !== null || !hasSessionStartHook && stableIdle && hasAssistantEvents); 491: const reviewTimedOut = task.isRemoteReview && Date.now() - task.pollStartedAt > REMOTE_REVIEW_TIMEOUT_MS; 492: const newStatus = result ? result.subtype === 'success' ? 'completed' as const : 'failed' as const : sessionDone || reviewTimedOut ? 'completed' as const : accumulatedLog.length > 0 ? 'running' as const : 'starting' as const; 493: let raceTerminated = false; 494: updateTaskState<RemoteAgentTaskState>(taskId, context.setAppState, prevTask => { 495: if (prevTask.status !== 'running') { 496: raceTerminated = true; 497: return prevTask; 498: } 499: const statusUnchanged = newStatus === 'running' || newStatus === 'starting'; 500: if (!logGrew && statusUnchanged) { 501: return prevTask; 502: } 503: return { 504: ...prevTask, 505: status: newStatus === 'starting' ? 'running' : newStatus, 506: log: accumulatedLog, 507: todoList: logGrew ? extractTodoListFromLog(accumulatedLog) : prevTask.todoList, 508: reviewProgress: newProgress ?? prevTask.reviewProgress, 509: endTime: result || sessionDone || reviewTimedOut ? Date.now() : undefined 510: }; 511: }); 512: if (raceTerminated) return; 513: if (result || sessionDone || reviewTimedOut) { 514: const finalStatus = result && result.subtype !== 'success' ? 'failed' : 'completed'; 515: if (task.isRemoteReview) { 516: const reviewContent = cachedReviewContent ?? extractReviewFromLog(accumulatedLog); 517: if (reviewContent && finalStatus === 'completed') { 518: enqueueRemoteReviewNotification(taskId, reviewContent, context.setAppState); 519: void evictTaskOutput(taskId); 520: void removeRemoteAgentMetadata(taskId); 521: return; 522: } 523: updateTaskState(taskId, context.setAppState, t => ({ 524: ...t, 525: status: 'failed' 526: })); 527: const reason = result && result.subtype !== 'success' ? 'remote session returned an error' : reviewTimedOut && !sessionDone ? 'remote session exceeded 30 minutes' : 'no review output — orchestrator may have exited early'; 528: enqueueRemoteReviewFailureNotification(taskId, reason, context.setAppState); 529: void evictTaskOutput(taskId); 530: void removeRemoteAgentMetadata(taskId); 531: return; 532: } 533: enqueueRemoteNotification(taskId, task.title, finalStatus, context.setAppState, task.toolUseId); 534: void evictTaskOutput(taskId); 535: void removeRemoteAgentMetadata(taskId); 536: return; 537: } 538: } catch (error) { 539: logError(error); 540: consecutiveIdlePolls = 0; 541: try { 542: const appState = context.getAppState(); 543: const task = appState.tasks?.[taskId] as RemoteAgentTaskState | undefined; 544: if (task?.isRemoteReview && task.status === 'running' && Date.now() - task.pollStartedAt > REMOTE_REVIEW_TIMEOUT_MS) { 545: updateTaskState(taskId, context.setAppState, t => ({ 546: ...t, 547: status: 'failed', 548: endTime: Date.now() 549: })); 550: enqueueRemoteReviewFailureNotification(taskId, 'remote session exceeded 30 minutes', context.setAppState); 551: void evictTaskOutput(taskId); 552: void removeRemoteAgentMetadata(taskId); 553: return; 554: } 555: } catch { 556: } 557: } 558: if (isRunning) { 559: setTimeout(poll, POLL_INTERVAL_MS); 560: } 561: }; 562: void poll(); 563: return () => { 564: isRunning = false; 565: }; 566: } 567: export const RemoteAgentTask: Task = { 568: name: 'RemoteAgentTask', 569: type: 'remote_agent', 570: async kill(taskId, setAppState) { 571: let toolUseId: string | undefined; 572: let description: string | undefined; 573: let sessionId: string | undefined; 574: let killed = false; 575: updateTaskState<RemoteAgentTaskState>(taskId, setAppState, task => { 576: if (task.status !== 'running') { 577: return task; 578: } 579: toolUseId = task.toolUseId; 580: description = task.description; 581: sessionId = task.sessionId; 582: killed = true; 583: return { 584: ...task, 585: status: 'killed', 586: notified: true, 587: endTime: Date.now() 588: }; 589: }); 590: if (killed) { 591: emitTaskTerminatedSdk(taskId, 'stopped', { 592: toolUseId, 593: summary: description 594: }); 595: if (sessionId) { 596: void archiveRemoteSession(sessionId).catch(e => logForDebugging(`RemoteAgentTask archive failed: ${String(e)}`)); 597: } 598: } 599: void evictTaskOutput(taskId); 600: void removeRemoteAgentMetadata(taskId); 601: logForDebugging(`RemoteAgentTask ${taskId} killed, archiving session ${sessionId ?? 'unknown'}`); 602: } 603: }; 604: export function getRemoteTaskSessionUrl(sessionId: string): string { 605: return getRemoteSessionUrl(sessionId, process.env.SESSION_INGRESS_URL); 606: }

File: src/tasks/LocalMainSessionTask.ts

typescript 1: import type { UUID } from 'crypto' 2: import { randomBytes } from 'crypto' 3: import { 4: OUTPUT_FILE_TAG, 5: STATUS_TAG, 6: SUMMARY_TAG, 7: TASK_ID_TAG, 8: TASK_NOTIFICATION_TAG, 9: TOOL_USE_ID_TAG, 10: } from '../constants/xml.js' 11: import { type QueryParams, query } from '../query.js' 12: import { roughTokenCountEstimation } from '../services/tokenEstimation.js' 13: import type { SetAppState } from '../Task.js' 14: import { createTaskStateBase } from '../Task.js' 15: import type { 16: AgentDefinition, 17: CustomAgentDefinition, 18: } from '../tools/AgentTool/loadAgentsDir.js' 19: import { asAgentId } from '../types/ids.js' 20: import type { Message } from '../types/message.js' 21: import { createAbortController } from '../utils/abortController.js' 22: import { 23: runWithAgentContext, 24: type SubagentContext, 25: } from '../utils/agentContext.js' 26: import { registerCleanup } from '../utils/cleanupRegistry.js' 27: import { logForDebugging } from '../utils/debug.js' 28: import { logError } from '../utils/log.js' 29: import { enqueuePendingNotification } from '../utils/messageQueueManager.js' 30: import { emitTaskTerminatedSdk } from '../utils/sdkEventQueue.js' 31: import { 32: getAgentTranscriptPath, 33: recordSidechainTranscript, 34: } from '../utils/sessionStorage.js' 35: import { 36: evictTaskOutput, 37: getTaskOutputPath, 38: initTaskOutputAsSymlink, 39: } from '../utils/task/diskOutput.js' 40: import { registerTask, updateTaskState } from '../utils/task/framework.js' 41: import type { LocalAgentTaskState } from './LocalAgentTask/LocalAgentTask.js' 42: export type LocalMainSessionTaskState = LocalAgentTaskState & { 43: agentType: 'main-session' 44: } 45: const DEFAULT_MAIN_SESSION_AGENT: CustomAgentDefinition = { 46: agentType: 'main-session', 47: whenToUse: 'Main session query', 48: source: 'userSettings', 49: getSystemPrompt: () => '', 50: } 51: /** 52: * Generate a unique task ID for main session tasks. 53: * Uses 's' prefix to distinguish from agent tasks ('a' prefix). 54: */ 55: const TASK_ID_ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz' 56: function generateMainSessionTaskId(): string { 57: const bytes = randomBytes(8) 58: let id = 's' 59: for (let i = 0; i < 8; i++) { 60: id += TASK_ID_ALPHABET[bytes[i]! % TASK_ID_ALPHABET.length] 61: } 62: return id 63: } 64: export function registerMainSessionTask( 65: description: string, 66: setAppState: SetAppState, 67: mainThreadAgentDefinition?: AgentDefinition, 68: existingAbortController?: AbortController, 69: ): { taskId: string; abortSignal: AbortSignal } { 70: const taskId = generateMainSessionTaskId() 71: void initTaskOutputAsSymlink( 72: taskId, 73: getAgentTranscriptPath(asAgentId(taskId)), 74: ) 75: const abortController = existingAbortController ?? createAbortController() 76: const unregisterCleanup = registerCleanup(async () => { 77: setAppState(prev => { 78: const { [taskId]: removed, ...rest } = prev.tasks 79: return { ...prev, tasks: rest } 80: }) 81: }) 82: const selectedAgent = mainThreadAgentDefinition ?? DEFAULT_MAIN_SESSION_AGENT 83: const taskState: LocalMainSessionTaskState = { 84: ...createTaskStateBase(taskId, 'local_agent', description), 85: type: 'local_agent', 86: status: 'running', 87: agentId: taskId, 88: prompt: description, 89: selectedAgent, 90: agentType: 'main-session', 91: abortController, 92: unregisterCleanup, 93: retrieved: false, 94: lastReportedToolCount: 0, 95: lastReportedTokenCount: 0, 96: isBackgrounded: true, 97: pendingMessages: [], 98: retain: false, 99: diskLoaded: false, 100: } 101: logForDebugging( 102: `[LocalMainSessionTask] Registering task ${taskId} with description: ${description}`, 103: ) 104: registerTask(taskState, setAppState) 105: setAppState(prev => { 106: const hasTask = taskId in prev.tasks 107: logForDebugging( 108: `[LocalMainSessionTask] After registration, task ${taskId} exists in state: ${hasTask}`, 109: ) 110: return prev 111: }) 112: return { taskId, abortSignal: abortController.signal } 113: } 114: export function completeMainSessionTask( 115: taskId: string, 116: success: boolean, 117: setAppState: SetAppState, 118: ): void { 119: let wasBackgrounded = true 120: let toolUseId: string | undefined 121: updateTaskState<LocalMainSessionTaskState>(taskId, setAppState, task => { 122: if (task.status !== 'running') { 123: return task 124: } 125: wasBackgrounded = task.isBackgrounded ?? true 126: toolUseId = task.toolUseId 127: task.unregisterCleanup?.() 128: return { 129: ...task, 130: status: success ? 'completed' : 'failed', 131: endTime: Date.now(), 132: messages: task.messages?.length ? [task.messages.at(-1)!] : undefined, 133: } 134: }) 135: void evictTaskOutput(taskId) 136: if (wasBackgrounded) { 137: enqueueMainSessionNotification( 138: taskId, 139: 'Background session', 140: success ? 'completed' : 'failed', 141: setAppState, 142: toolUseId, 143: ) 144: } else { 145: updateTaskState(taskId, setAppState, task => ({ ...task, notified: true })) 146: emitTaskTerminatedSdk(taskId, success ? 'completed' : 'failed', { 147: toolUseId, 148: summary: 'Background session', 149: }) 150: } 151: } 152: function enqueueMainSessionNotification( 153: taskId: string, 154: description: string, 155: status: 'completed' | 'failed', 156: setAppState: SetAppState, 157: toolUseId?: string, 158: ): void { 159: let shouldEnqueue = false 160: updateTaskState(taskId, setAppState, task => { 161: if (task.notified) { 162: return task 163: } 164: shouldEnqueue = true 165: return { ...task, notified: true } 166: }) 167: if (!shouldEnqueue) { 168: return 169: } 170: const summary = 171: status === 'completed' 172: ? `Background session "${description}" completed` 173: : `Background session "${description}" failed` 174: const toolUseIdLine = toolUseId 175: ? `\n<${TOOL_USE_ID_TAG}>${toolUseId}</${TOOL_USE_ID_TAG}>` 176: : '' 177: const outputPath = getTaskOutputPath(taskId) 178: const message = `<${TASK_NOTIFICATION_TAG}> 179: <${TASK_ID_TAG}>${taskId}</${TASK_ID_TAG}>${toolUseIdLine} 180: <${OUTPUT_FILE_TAG}>${outputPath}</${OUTPUT_FILE_TAG}> 181: <${STATUS_TAG}>${status}</${STATUS_TAG}> 182: <${SUMMARY_TAG}>${summary}</${SUMMARY_TAG}> 183: </${TASK_NOTIFICATION_TAG}>` 184: enqueuePendingNotification({ value: message, mode: 'task-notification' }) 185: } 186: export function foregroundMainSessionTask( 187: taskId: string, 188: setAppState: SetAppState, 189: ): Message[] | undefined { 190: let taskMessages: Message[] | undefined 191: setAppState(prev => { 192: const task = prev.tasks[taskId] 193: if (!task || task.type !== 'local_agent') { 194: return prev 195: } 196: taskMessages = (task as LocalMainSessionTaskState).messages 197: const prevId = prev.foregroundedTaskId 198: const prevTask = prevId ? prev.tasks[prevId] : undefined 199: const restorePrev = 200: prevId && prevId !== taskId && prevTask?.type === 'local_agent' 201: return { 202: ...prev, 203: foregroundedTaskId: taskId, 204: tasks: { 205: ...prev.tasks, 206: ...(restorePrev && { [prevId]: { ...prevTask, isBackgrounded: true } }), 207: [taskId]: { ...task, isBackgrounded: false }, 208: }, 209: } 210: }) 211: return taskMessages 212: } 213: export function isMainSessionTask( 214: task: unknown, 215: ): task is LocalMainSessionTaskState { 216: if ( 217: typeof task !== 'object' || 218: task === null || 219: !('type' in task) || 220: !('agentType' in task) 221: ) { 222: return false 223: } 224: return ( 225: task.type === 'local_agent' && 226: (task as LocalMainSessionTaskState).agentType === 'main-session' 227: ) 228: } 229: const MAX_RECENT_ACTIVITIES = 5 230: type ToolActivity = { 231: toolName: string 232: input: Record<string, unknown> 233: } 234: export function startBackgroundSession({ 235: messages, 236: queryParams, 237: description, 238: setAppState, 239: agentDefinition, 240: }: { 241: messages: Message[] 242: queryParams: Omit<QueryParams, 'messages'> 243: description: string 244: setAppState: SetAppState 245: agentDefinition?: AgentDefinition 246: }): string { 247: const { taskId, abortSignal } = registerMainSessionTask( 248: description, 249: setAppState, 250: agentDefinition, 251: ) 252: void recordSidechainTranscript(messages, taskId).catch(err => 253: logForDebugging(`bg-session initial transcript write failed: ${err}`), 254: ) 255: const agentContext: SubagentContext = { 256: agentId: taskId, 257: agentType: 'subagent', 258: subagentName: 'main-session', 259: isBuiltIn: true, 260: } 261: void runWithAgentContext(agentContext, async () => { 262: try { 263: const bgMessages: Message[] = [...messages] 264: const recentActivities: ToolActivity[] = [] 265: let toolCount = 0 266: let tokenCount = 0 267: let lastRecordedUuid: UUID | null = messages.at(-1)?.uuid ?? null 268: for await (const event of query({ 269: messages: bgMessages, 270: ...queryParams, 271: })) { 272: if (abortSignal.aborted) { 273: let alreadyNotified = false 274: updateTaskState(taskId, setAppState, task => { 275: alreadyNotified = task.notified === true 276: return alreadyNotified ? task : { ...task, notified: true } 277: }) 278: if (!alreadyNotified) { 279: emitTaskTerminatedSdk(taskId, 'stopped', { 280: summary: description, 281: }) 282: } 283: return 284: } 285: if ( 286: event.type !== 'user' && 287: event.type !== 'assistant' && 288: event.type !== 'system' 289: ) { 290: continue 291: } 292: bgMessages.push(event) 293: void recordSidechainTranscript([event], taskId, lastRecordedUuid).catch( 294: err => logForDebugging(`bg-session transcript write failed: ${err}`), 295: ) 296: lastRecordedUuid = event.uuid 297: if (event.type === 'assistant') { 298: for (const block of event.message.content) { 299: if (block.type === 'text') { 300: tokenCount += roughTokenCountEstimation(block.text) 301: } else if (block.type === 'tool_use') { 302: toolCount++ 303: const activity: ToolActivity = { 304: toolName: block.name, 305: input: block.input as Record<string, unknown>, 306: } 307: recentActivities.push(activity) 308: if (recentActivities.length > MAX_RECENT_ACTIVITIES) { 309: recentActivities.shift() 310: } 311: } 312: } 313: } 314: setAppState(prev => { 315: const task = prev.tasks[taskId] 316: if (!task || task.type !== 'local_agent') return prev 317: const prevProgress = task.progress 318: if ( 319: prevProgress?.tokenCount === tokenCount && 320: prevProgress.toolUseCount === toolCount && 321: task.messages === bgMessages 322: ) { 323: return prev 324: } 325: return { 326: ...prev, 327: tasks: { 328: ...prev.tasks, 329: [taskId]: { 330: ...task, 331: progress: { 332: tokenCount, 333: toolUseCount: toolCount, 334: recentActivities: 335: prevProgress?.toolUseCount === toolCount 336: ? prevProgress.recentActivities 337: : [...recentActivities], 338: }, 339: messages: bgMessages, 340: }, 341: }, 342: } 343: }) 344: } 345: completeMainSessionTask(taskId, true, setAppState) 346: } catch (error) { 347: logError(error) 348: completeMainSessionTask(taskId, false, setAppState) 349: } 350: }) 351: return taskId 352: }

File: src/tasks/pillLabel.ts

typescript 1: import { DIAMOND_FILLED, DIAMOND_OPEN } from '../constants/figures.js' 2: import { count } from '../utils/array.js' 3: import type { BackgroundTaskState } from './types.js' 4: export function getPillLabel(tasks: BackgroundTaskState[]): string { 5: const n = tasks.length 6: const allSameType = tasks.every(t => t.type === tasks[0]!.type) 7: if (allSameType) { 8: switch (tasks[0]!.type) { 9: case 'local_bash': { 10: const monitors = count( 11: tasks, 12: t => t.type === 'local_bash' && t.kind === 'monitor', 13: ) 14: const shells = n - monitors 15: const parts: string[] = [] 16: if (shells > 0) 17: parts.push(shells === 1 ? '1 shell' : `${shells} shells`) 18: if (monitors > 0) 19: parts.push(monitors === 1 ? '1 monitor' : `${monitors} monitors`) 20: return parts.join(', ') 21: } 22: case 'in_process_teammate': { 23: const teamCount = new Set( 24: tasks.map(t => 25: t.type === 'in_process_teammate' ? t.identity.teamName : '', 26: ), 27: ).size 28: return teamCount === 1 ? '1 team' : `${teamCount} teams` 29: } 30: case 'local_agent': 31: return n === 1 ? '1 local agent' : `${n} local agents` 32: case 'remote_agent': { 33: const first = tasks[0]! 34: if (n === 1 && first.type === 'remote_agent' && first.isUltraplan) { 35: switch (first.ultraplanPhase) { 36: case 'plan_ready': 37: return `${DIAMOND_FILLED} ultraplan ready` 38: case 'needs_input': 39: return `${DIAMOND_OPEN} ultraplan needs your input` 40: default: 41: return `${DIAMOND_OPEN} ultraplan` 42: } 43: } 44: return n === 1 45: ? `${DIAMOND_OPEN} 1 cloud session` 46: : `${DIAMOND_OPEN} ${n} cloud sessions` 47: } 48: case 'local_workflow': 49: return n === 1 ? '1 background workflow' : `${n} background workflows` 50: case 'monitor_mcp': 51: return n === 1 ? '1 monitor' : `${n} monitors` 52: case 'dream': 53: return 'dreaming' 54: } 55: } 56: return `${n} background ${n === 1 ? 'task' : 'tasks'}` 57: } 58: export function pillNeedsCta(tasks: BackgroundTaskState[]): boolean { 59: if (tasks.length !== 1) return false 60: const t = tasks[0]! 61: return ( 62: t.type === 'remote_agent' && 63: t.isUltraplan === true && 64: t.ultraplanPhase !== undefined 65: ) 66: }

File: src/tasks/stopTask.ts

typescript 1: import type { AppState } from '../state/AppState.js' 2: import type { TaskStateBase } from '../Task.js' 3: import { getTaskByType } from '../tasks.js' 4: import { emitTaskTerminatedSdk } from '../utils/sdkEventQueue.js' 5: import { isLocalShellTask } from './LocalShellTask/guards.js' 6: export class StopTaskError extends Error { 7: constructor( 8: message: string, 9: public readonly code: 'not_found' | 'not_running' | 'unsupported_type', 10: ) { 11: super(message) 12: this.name = 'StopTaskError' 13: } 14: } 15: type StopTaskContext = { 16: getAppState: () => AppState 17: setAppState: (f: (prev: AppState) => AppState) => void 18: } 19: type StopTaskResult = { 20: taskId: string 21: taskType: string 22: command: string | undefined 23: } 24: export async function stopTask( 25: taskId: string, 26: context: StopTaskContext, 27: ): Promise<StopTaskResult> { 28: const { getAppState, setAppState } = context 29: const appState = getAppState() 30: const task = appState.tasks?.[taskId] as TaskStateBase | undefined 31: if (!task) { 32: throw new StopTaskError(`No task found with ID: ${taskId}`, 'not_found') 33: } 34: if (task.status !== 'running') { 35: throw new StopTaskError( 36: `Task ${taskId} is not running (status: ${task.status})`, 37: 'not_running', 38: ) 39: } 40: const taskImpl = getTaskByType(task.type) 41: if (!taskImpl) { 42: throw new StopTaskError( 43: `Unsupported task type: ${task.type}`, 44: 'unsupported_type', 45: ) 46: } 47: await taskImpl.kill(taskId, setAppState) 48: if (isLocalShellTask(task)) { 49: let suppressed = false 50: setAppState(prev => { 51: const prevTask = prev.tasks[taskId] 52: if (!prevTask || prevTask.notified) { 53: return prev 54: } 55: suppressed = true 56: return { 57: ...prev, 58: tasks: { 59: ...prev.tasks, 60: [taskId]: { ...prevTask, notified: true }, 61: }, 62: } 63: }) 64: if (suppressed) { 65: emitTaskTerminatedSdk(taskId, 'stopped', { 66: toolUseId: task.toolUseId, 67: summary: task.description, 68: }) 69: } 70: } 71: const command = isLocalShellTask(task) ? task.command : task.description 72: return { taskId, taskType: task.type, command } 73: }

File: src/tasks/types.ts

typescript 1: import type { DreamTaskState } from './DreamTask/DreamTask.js' 2: import type { InProcessTeammateTaskState } from './InProcessTeammateTask/types.js' 3: import type { LocalAgentTaskState } from './LocalAgentTask/LocalAgentTask.js' 4: import type { LocalShellTaskState } from './LocalShellTask/guards.js' 5: import type { LocalWorkflowTaskState } from './LocalWorkflowTask/LocalWorkflowTask.js' 6: import type { MonitorMcpTaskState } from './MonitorMcpTask/MonitorMcpTask.js' 7: import type { RemoteAgentTaskState } from './RemoteAgentTask/RemoteAgentTask.js' 8: export type TaskState = 9: | LocalShellTaskState 10: | LocalAgentTaskState 11: | RemoteAgentTaskState 12: | InProcessTeammateTaskState 13: | LocalWorkflowTaskState 14: | MonitorMcpTaskState 15: | DreamTaskState 16: export type BackgroundTaskState = 17: | LocalShellTaskState 18: | LocalAgentTaskState 19: | RemoteAgentTaskState 20: | InProcessTeammateTaskState 21: | LocalWorkflowTaskState 22: | MonitorMcpTaskState 23: | DreamTaskState 24: export function isBackgroundTask(task: TaskState): task is BackgroundTaskState { 25: if (task.status !== 'running' && task.status !== 'pending') { 26: return false 27: } 28: if ('isBackgrounded' in task && task.isBackgrounded === false) { 29: return false 30: } 31: return true 32: }

File: src/tools/AgentTool/built-in/claudeCodeGuideAgent.ts

typescript 1: import { BASH_TOOL_NAME } from 'src/tools/BashTool/toolName.js' 2: import { FILE_READ_TOOL_NAME } from 'src/tools/FileReadTool/prompt.js' 3: import { GLOB_TOOL_NAME } from 'src/tools/GlobTool/prompt.js' 4: import { GREP_TOOL_NAME } from 'src/tools/GrepTool/prompt.js' 5: import { SEND_MESSAGE_TOOL_NAME } from 'src/tools/SendMessageTool/constants.js' 6: import { WEB_FETCH_TOOL_NAME } from 'src/tools/WebFetchTool/prompt.js' 7: import { WEB_SEARCH_TOOL_NAME } from 'src/tools/WebSearchTool/prompt.js' 8: import { isUsing3PServices } from 'src/utils/auth.js' 9: import { hasEmbeddedSearchTools } from 'src/utils/embeddedTools.js' 10: import { getSettings_DEPRECATED } from 'src/utils/settings/settings.js' 11: import { jsonStringify } from '../../../utils/slowOperations.js' 12: import type { 13: AgentDefinition, 14: BuiltInAgentDefinition, 15: } from '../loadAgentsDir.js' 16: const CLAUDE_CODE_DOCS_MAP_URL = 17: 'https://code.claude.com/docs/en/claude_code_docs_map.md' 18: const CDP_DOCS_MAP_URL = 'https://platform.claude.com/llms.txt' 19: export const CLAUDE_CODE_GUIDE_AGENT_TYPE = 'claude-code-guide' 20: function getClaudeCodeGuideBasePrompt(): string { 21: const localSearchHint = hasEmbeddedSearchTools() 22: ? `${FILE_READ_TOOL_NAME}, \`find\`, and \`grep\`` 23: : `${FILE_READ_TOOL_NAME}, ${GLOB_TOOL_NAME}, and ${GREP_TOOL_NAME}` 24: return `You are the Claude guide agent. Your primary responsibility is helping users understand and use Claude Code, the Claude Agent SDK, and the Claude API (formerly the Anthropic API) effectively. 25: **Your expertise spans three domains:** 26: 1. **Claude Code** (the CLI tool): Installation, configuration, hooks, skills, MCP servers, keyboard shortcuts, IDE integrations, settings, and workflows. 27: 2. **Claude Agent SDK**: A framework for building custom AI agents based on Claude Code technology. Available for Node.js/TypeScript and Python. 28: 3. **Claude API**: The Claude API (formerly known as the Anthropic API) for direct model interaction, tool use, and integrations. 29: **Documentation sources:** 30: - **Claude Code docs** (${CLAUDE_CODE_DOCS_MAP_URL}): Fetch this for questions about the Claude Code CLI tool, including: 31: - Installation, setup, and getting started 32: - Hooks (pre/post command execution) 33: - Custom skills 34: - MCP server configuration 35: - IDE integrations (VS Code, JetBrains) 36: - Settings files and configuration 37: - Keyboard shortcuts and hotkeys 38: - Subagents and plugins 39: - Sandboxing and security 40: - **Claude Agent SDK docs** (${CDP_DOCS_MAP_URL}): Fetch this for questions about building agents with the SDK, including: 41: - SDK overview and getting started (Python and TypeScript) 42: - Agent configuration + custom tools 43: - Session management and permissions 44: - MCP integration in agents 45: - Hosting and deployment 46: - Cost tracking and context management 47: Note: Agent SDK docs are part of the Claude API documentation at the same URL. 48: - **Claude API docs** (${CDP_DOCS_MAP_URL}): Fetch this for questions about the Claude API (formerly the Anthropic API), including: 49: - Messages API and streaming 50: - Tool use (function calling) and Anthropic-defined tools (computer use, code execution, web search, text editor, bash, programmatic tool calling, tool search tool, context editing, Files API, structured outputs) 51: - Vision, PDF support, and citations 52: - Extended thinking and structured outputs 53: - MCP connector for remote MCP servers 54: - Cloud provider integrations (Bedrock, Vertex AI, Foundry) 55: **Approach:** 56: 1. Determine which domain the user's question falls into 57: 2. Use ${WEB_FETCH_TOOL_NAME} to fetch the appropriate docs map 58: 3. Identify the most relevant documentation URLs from the map 59: 4. Fetch the specific documentation pages 60: 5. Provide clear, actionable guidance based on official documentation 61: 6. Use ${WEB_SEARCH_TOOL_NAME} if docs don't cover the topic 62: 7. Reference local project files (CLAUDE.md, .claude/ directory) when relevant using ${localSearchHint} 63: **Guidelines:** 64: - Always prioritize official documentation over assumptions 65: - Keep responses concise and actionable 66: - Include specific examples or code snippets when helpful 67: - Reference exact documentation URLs in your responses 68: - Help users discover features by proactively suggesting related commands, shortcuts, or capabilities 69: Complete the user's request by providing accurate, documentation-based guidance.` 70: } 71: function getFeedbackGuideline(): string { 72: if (isUsing3PServices()) { 73: return `- When you cannot find an answer or the feature doesn't exist, direct the user to ${MACRO.ISSUES_EXPLAINER}` 74: } 75: return "- When you cannot find an answer or the feature doesn't exist, direct the user to use /feedback to report a feature request or bug" 76: } 77: export const CLAUDE_CODE_GUIDE_AGENT: BuiltInAgentDefinition = { 78: agentType: CLAUDE_CODE_GUIDE_AGENT_TYPE, 79: whenToUse: `Use this agent when the user asks questions ("Can Claude...", "Does Claude...", "How do I...") about: (1) Claude Code (the CLI tool) - features, hooks, slash commands, MCP servers, settings, IDE integrations, keyboard shortcuts; (2) Claude Agent SDK - building custom agents; (3) Claude API (formerly Anthropic API) - API usage, tool use, Anthropic SDK usage. **IMPORTANT:** Before spawning a new agent, check if there is already a running or recently completed claude-code-guide agent that you can continue via ${SEND_MESSAGE_TOOL_NAME}.`, 80: tools: hasEmbeddedSearchTools() 81: ? [ 82: BASH_TOOL_NAME, 83: FILE_READ_TOOL_NAME, 84: WEB_FETCH_TOOL_NAME, 85: WEB_SEARCH_TOOL_NAME, 86: ] 87: : [ 88: GLOB_TOOL_NAME, 89: GREP_TOOL_NAME, 90: FILE_READ_TOOL_NAME, 91: WEB_FETCH_TOOL_NAME, 92: WEB_SEARCH_TOOL_NAME, 93: ], 94: source: 'built-in', 95: baseDir: 'built-in', 96: model: 'haiku', 97: permissionMode: 'dontAsk', 98: getSystemPrompt({ toolUseContext }) { 99: const commands = toolUseContext.options.commands 100: const contextSections: string[] = [] 101: const customCommands = commands.filter(cmd => cmd.type === 'prompt') 102: if (customCommands.length > 0) { 103: const commandList = customCommands 104: .map(cmd => `- /${cmd.name}: ${cmd.description}`) 105: .join('\n') 106: contextSections.push( 107: `**Available custom skills in this project:**\n${commandList}`, 108: ) 109: } 110: const customAgents = 111: toolUseContext.options.agentDefinitions.activeAgents.filter( 112: (a: AgentDefinition) => a.source !== 'built-in', 113: ) 114: if (customAgents.length > 0) { 115: const agentList = customAgents 116: .map((a: AgentDefinition) => `- ${a.agentType}: ${a.whenToUse}`) 117: .join('\n') 118: contextSections.push( 119: `**Available custom agents configured:**\n${agentList}`, 120: ) 121: } 122: const mcpClients = toolUseContext.options.mcpClients 123: if (mcpClients && mcpClients.length > 0) { 124: const mcpList = mcpClients 125: .map((client: { name: string }) => `- ${client.name}`) 126: .join('\n') 127: contextSections.push(`**Configured MCP servers:**\n${mcpList}`) 128: } 129: const pluginCommands = commands.filter( 130: cmd => cmd.type === 'prompt' && cmd.source === 'plugin', 131: ) 132: if (pluginCommands.length > 0) { 133: const pluginList = pluginCommands 134: .map(cmd => `- /${cmd.name}: ${cmd.description}`) 135: .join('\n') 136: contextSections.push(`**Available plugin skills:**\n${pluginList}`) 137: } 138: const settings = getSettings_DEPRECATED() 139: if (Object.keys(settings).length > 0) { 140: const settingsJson = jsonStringify(settings, null, 2) 141: contextSections.push( 142: `**User's settings.json:**\n\`\`\`json\n${settingsJson}\n\`\`\``, 143: ) 144: } 145: const feedbackGuideline = getFeedbackGuideline() 146: const basePromptWithFeedback = `${getClaudeCodeGuideBasePrompt()} 147: ${feedbackGuideline}` 148: if (contextSections.length > 0) { 149: return `${basePromptWithFeedback} 150: --- 151: # User's Current Configuration 152: The user has the following custom setup in their environment: 153: ${contextSections.join('\n\n')} 154: When answering questions, consider these configured features and proactively suggest them when relevant.` 155: } 156: return basePromptWithFeedback 157: }, 158: }

File: src/tools/AgentTool/built-in/exploreAgent.ts

typescript 1: import { BASH_TOOL_NAME } from 'src/tools/BashTool/toolName.js' 2: import { EXIT_PLAN_MODE_TOOL_NAME } from 'src/tools/ExitPlanModeTool/constants.js' 3: import { FILE_EDIT_TOOL_NAME } from 'src/tools/FileEditTool/constants.js' 4: import { FILE_READ_TOOL_NAME } from 'src/tools/FileReadTool/prompt.js' 5: import { FILE_WRITE_TOOL_NAME } from 'src/tools/FileWriteTool/prompt.js' 6: import { GLOB_TOOL_NAME } from 'src/tools/GlobTool/prompt.js' 7: import { GREP_TOOL_NAME } from 'src/tools/GrepTool/prompt.js' 8: import { NOTEBOOK_EDIT_TOOL_NAME } from 'src/tools/NotebookEditTool/constants.js' 9: import { hasEmbeddedSearchTools } from 'src/utils/embeddedTools.js' 10: import { AGENT_TOOL_NAME } from '../constants.js' 11: import type { BuiltInAgentDefinition } from '../loadAgentsDir.js' 12: function getExploreSystemPrompt(): string { 13: const embedded = hasEmbeddedSearchTools() 14: const globGuidance = embedded 15: ? `- Use \`find\` via ${BASH_TOOL_NAME} for broad file pattern matching` 16: : `- Use ${GLOB_TOOL_NAME} for broad file pattern matching` 17: const grepGuidance = embedded 18: ? `- Use \`grep\` via ${BASH_TOOL_NAME} for searching file contents with regex` 19: : `- Use ${GREP_TOOL_NAME} for searching file contents with regex` 20: return `You are a file search specialist for Claude Code, Anthropic's official CLI for Claude. You excel at thoroughly navigating and exploring codebases. 21: === CRITICAL: READ-ONLY MODE - NO FILE MODIFICATIONS === 22: This is a READ-ONLY exploration task. You are STRICTLY PROHIBITED from: 23: - Creating new files (no Write, touch, or file creation of any kind) 24: - Modifying existing files (no Edit operations) 25: - Deleting files (no rm or deletion) 26: - Moving or copying files (no mv or cp) 27: - Creating temporary files anywhere, including /tmp 28: - Using redirect operators (>, >>, |) or heredocs to write to files 29: - Running ANY commands that change system state 30: Your role is EXCLUSIVELY to search and analyze existing code. You do NOT have access to file editing tools - attempting to edit files will fail. 31: Your strengths: 32: - Rapidly finding files using glob patterns 33: - Searching code and text with powerful regex patterns 34: - Reading and analyzing file contents 35: Guidelines: 36: ${globGuidance} 37: ${grepGuidance} 38: - Use ${FILE_READ_TOOL_NAME} when you know the specific file path you need to read 39: - Use ${BASH_TOOL_NAME} ONLY for read-only operations (ls, git status, git log, git diff, find${embedded ? ', grep' : ''}, cat, head, tail) 40: - NEVER use ${BASH_TOOL_NAME} for: mkdir, touch, rm, cp, mv, git add, git commit, npm install, pip install, or any file creation/modification 41: - Adapt your search approach based on the thoroughness level specified by the caller 42: - Communicate your final report directly as a regular message - do NOT attempt to create files 43: NOTE: You are meant to be a fast agent that returns output as quickly as possible. In order to achieve this you must: 44: - Make efficient use of the tools that you have at your disposal: be smart about how you search for files and implementations 45: - Wherever possible you should try to spawn multiple parallel tool calls for grepping and reading files 46: Complete the user's search request efficiently and report your findings clearly.` 47: } 48: export const EXPLORE_AGENT_MIN_QUERIES = 3 49: const EXPLORE_WHEN_TO_USE = 50: 'Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for comprehensive analysis across multiple locations and naming conventions.' 51: export const EXPLORE_AGENT: BuiltInAgentDefinition = { 52: agentType: 'Explore', 53: whenToUse: EXPLORE_WHEN_TO_USE, 54: disallowedTools: [ 55: AGENT_TOOL_NAME, 56: EXIT_PLAN_MODE_TOOL_NAME, 57: FILE_EDIT_TOOL_NAME, 58: FILE_WRITE_TOOL_NAME, 59: NOTEBOOK_EDIT_TOOL_NAME, 60: ], 61: source: 'built-in', 62: baseDir: 'built-in', 63: model: process.env.USER_TYPE === 'ant' ? 'inherit' : 'haiku', 64: omitClaudeMd: true, 65: getSystemPrompt: () => getExploreSystemPrompt(), 66: }

File: src/tools/AgentTool/built-in/generalPurposeAgent.ts

typescript 1: import type { BuiltInAgentDefinition } from '../loadAgentsDir.js' 2: const SHARED_PREFIX = `You are an agent for Claude Code, Anthropic's official CLI for Claude. Given the user's message, you should use the tools available to complete the task. Complete the task fully—don't gold-plate, but don't leave it half-done.` 3: const SHARED_GUIDELINES = `Your strengths: 4: - Searching for code, configurations, and patterns across large codebases 5: - Analyzing multiple files to understand system architecture 6: - Investigating complex questions that require exploring many files 7: - Performing multi-step research tasks 8: Guidelines: 9: - For file searches: search broadly when you don't know where something lives. Use Read when you know the specific file path. 10: - For analysis: Start broad and narrow down. Use multiple search strategies if the first doesn't yield results. 11: - Be thorough: Check multiple locations, consider different naming conventions, look for related files. 12: - NEVER create files unless they're absolutely necessary for achieving your goal. ALWAYS prefer editing an existing file to creating a new one. 13: - NEVER proactively create documentation files (*.md) or README files. Only create documentation files if explicitly requested.` 14: function getGeneralPurposeSystemPrompt(): string { 15: return `${SHARED_PREFIX} When you complete the task, respond with a concise report covering what was done and any key findings — the caller will relay this to the user, so it only needs the essentials. 16: ${SHARED_GUIDELINES}` 17: } 18: export const GENERAL_PURPOSE_AGENT: BuiltInAgentDefinition = { 19: agentType: 'general-purpose', 20: whenToUse: 21: 'General-purpose agent for researching complex questions, searching for code, and executing multi-step tasks. When you are searching for a keyword or file and are not confident that you will find the right match in the first few tries use this agent to perform the search for you.', 22: tools: ['*'], 23: source: 'built-in', 24: baseDir: 'built-in', 25: getSystemPrompt: getGeneralPurposeSystemPrompt, 26: }

File: src/tools/AgentTool/built-in/planAgent.ts

typescript 1: import { BASH_TOOL_NAME } from 'src/tools/BashTool/toolName.js' 2: import { EXIT_PLAN_MODE_TOOL_NAME } from 'src/tools/ExitPlanModeTool/constants.js' 3: import { FILE_EDIT_TOOL_NAME } from 'src/tools/FileEditTool/constants.js' 4: import { FILE_READ_TOOL_NAME } from 'src/tools/FileReadTool/prompt.js' 5: import { FILE_WRITE_TOOL_NAME } from 'src/tools/FileWriteTool/prompt.js' 6: import { GLOB_TOOL_NAME } from 'src/tools/GlobTool/prompt.js' 7: import { GREP_TOOL_NAME } from 'src/tools/GrepTool/prompt.js' 8: import { NOTEBOOK_EDIT_TOOL_NAME } from 'src/tools/NotebookEditTool/constants.js' 9: import { hasEmbeddedSearchTools } from 'src/utils/embeddedTools.js' 10: import { AGENT_TOOL_NAME } from '../constants.js' 11: import type { BuiltInAgentDefinition } from '../loadAgentsDir.js' 12: import { EXPLORE_AGENT } from './exploreAgent.js' 13: function getPlanV2SystemPrompt(): string { 14: const searchToolsHint = hasEmbeddedSearchTools() 15: ? `\`find\`, \`grep\`, and ${FILE_READ_TOOL_NAME}` 16: : `${GLOB_TOOL_NAME}, ${GREP_TOOL_NAME}, and ${FILE_READ_TOOL_NAME}` 17: return `You are a software architect and planning specialist for Claude Code. Your role is to explore the codebase and design implementation plans. 18: === CRITICAL: READ-ONLY MODE - NO FILE MODIFICATIONS === 19: This is a READ-ONLY planning task. You are STRICTLY PROHIBITED from: 20: - Creating new files (no Write, touch, or file creation of any kind) 21: - Modifying existing files (no Edit operations) 22: - Deleting files (no rm or deletion) 23: - Moving or copying files (no mv or cp) 24: - Creating temporary files anywhere, including /tmp 25: - Using redirect operators (>, >>, |) or heredocs to write to files 26: - Running ANY commands that change system state 27: Your role is EXCLUSIVELY to explore the codebase and design implementation plans. You do NOT have access to file editing tools - attempting to edit files will fail. 28: You will be provided with a set of requirements and optionally a perspective on how to approach the design process. 29: ## Your Process 30: 1. **Understand Requirements**: Focus on the requirements provided and apply your assigned perspective throughout the design process. 31: 2. **Explore Thoroughly**: 32: - Read any files provided to you in the initial prompt 33: - Find existing patterns and conventions using ${searchToolsHint} 34: - Understand the current architecture 35: - Identify similar features as reference 36: - Trace through relevant code paths 37: - Use ${BASH_TOOL_NAME} ONLY for read-only operations (ls, git status, git log, git diff, find${hasEmbeddedSearchTools() ? ', grep' : ''}, cat, head, tail) 38: - NEVER use ${BASH_TOOL_NAME} for: mkdir, touch, rm, cp, mv, git add, git commit, npm install, pip install, or any file creation/modification 39: 3. **Design Solution**: 40: - Create implementation approach based on your assigned perspective 41: - Consider trade-offs and architectural decisions 42: - Follow existing patterns where appropriate 43: 4. **Detail the Plan**: 44: - Provide step-by-step implementation strategy 45: - Identify dependencies and sequencing 46: - Anticipate potential challenges 47: ## Required Output 48: End your response with: 49: ### Critical Files for Implementation 50: List 3-5 files most critical for implementing this plan: 51: - path/to/file1.ts 52: - path/to/file2.ts 53: - path/to/file3.ts 54: REMEMBER: You can ONLY explore and plan. You CANNOT and MUST NOT write, edit, or modify any files. You do NOT have access to file editing tools.` 55: } 56: export const PLAN_AGENT: BuiltInAgentDefinition = { 57: agentType: 'Plan', 58: whenToUse: 59: 'Software architect agent for designing implementation plans. Use this when you need to plan the implementation strategy for a task. Returns step-by-step plans, identifies critical files, and considers architectural trade-offs.', 60: disallowedTools: [ 61: AGENT_TOOL_NAME, 62: EXIT_PLAN_MODE_TOOL_NAME, 63: FILE_EDIT_TOOL_NAME, 64: FILE_WRITE_TOOL_NAME, 65: NOTEBOOK_EDIT_TOOL_NAME, 66: ], 67: source: 'built-in', 68: tools: EXPLORE_AGENT.tools, 69: baseDir: 'built-in', 70: model: 'inherit', 71: omitClaudeMd: true, 72: getSystemPrompt: () => getPlanV2SystemPrompt(), 73: }

File: src/tools/AgentTool/built-in/statuslineSetup.ts

typescript 1: import type { BuiltInAgentDefinition } from '../loadAgentsDir.js' 2: const STATUSLINE_SYSTEM_PROMPT = `You are a status line setup agent for Claude Code. Your job is to create or update the statusLine command in the user's Claude Code settings. 3: When asked to convert the user's shell PS1 configuration, follow these steps: 4: 1. Read the user's shell configuration files in this order of preference: 5: - ~/.zshrc 6: - ~/.bashrc 7: - ~/.bash_profile 8: - ~/.profile 9: 2. Extract the PS1 value using this regex pattern: /(?:^|\\n)\\s*(?:export\\s+)?PS1\\s*=\\s*["']([^"']+)["']/m 10: 3. Convert PS1 escape sequences to shell commands: 11: - \\u → $(whoami) 12: - \\h → $(hostname -s) 13: - \\H → $(hostname) 14: - \\w → $(pwd) 15: - \\W → $(basename "$(pwd)") 16: - \\$ → $ 17: - \\n → \\n 18: - \\t → $(date +%H:%M:%S) 19: - \\d → $(date "+%a %b %d") 20: - \\@ → $(date +%I:%M%p) 21: - \\# → # 22: - \\! → ! 23: 4. When using ANSI color codes, be sure to use \`printf\`. Do not remove colors. Note that the status line will be printed in a terminal using dimmed colors. 24: 5. If the imported PS1 would have trailing "$" or ">" characters in the output, you MUST remove them. 25: 6. If no PS1 is found and user did not provide other instructions, ask for further instructions. 26: How to use the statusLine command: 27: 1. The statusLine command will receive the following JSON input via stdin: 28: { 29: "session_id": "string", // Unique session ID 30: "session_name": "string", // Optional: Human-readable session name set via /rename 31: "transcript_path": "string", // Path to the conversation transcript 32: "cwd": "string", // Current working directory 33: "model": { 34: "id": "string", // Model ID (e.g., "claude-3-5-sonnet-20241022") 35: "display_name": "string" // Display name (e.g., "Claude 3.5 Sonnet") 36: }, 37: "workspace": { 38: "current_dir": "string", // Current working directory path 39: "project_dir": "string", // Project root directory path 40: "added_dirs": ["string"] // Directories added via /add-dir 41: }, 42: "version": "string", // Claude Code app version (e.g., "1.0.71") 43: "output_style": { 44: "name": "string", // Output style name (e.g., "default", "Explanatory", "Learning") 45: }, 46: "context_window": { 47: "total_input_tokens": number, // Total input tokens used in session (cumulative) 48: "total_output_tokens": number, // Total output tokens used in session (cumulative) 49: "context_window_size": number, // Context window size for current model (e.g., 200000) 50: "current_usage": { // Token usage from last API call (null if no messages yet) 51: "input_tokens": number, // Input tokens for current context 52: "output_tokens": number, // Output tokens generated 53: "cache_creation_input_tokens": number, // Tokens written to cache 54: "cache_read_input_tokens": number // Tokens read from cache 55: } | null, 56: "used_percentage": number | null, // Pre-calculated: % of context used (0-100), null if no messages yet 57: "remaining_percentage": number | null // Pre-calculated: % of context remaining (0-100), null if no messages yet 58: }, 59: "rate_limits": { // Optional: Claude.ai subscription usage limits. Only present for subscribers after first API response. 60: "five_hour": { // Optional: 5-hour session limit (may be absent) 61: "used_percentage": number, // Percentage of limit used (0-100) 62: "resets_at": number // Unix epoch seconds when this window resets 63: }, 64: "seven_day": { // Optional: 7-day weekly limit (may be absent) 65: "used_percentage": number, // Percentage of limit used (0-100) 66: "resets_at": number // Unix epoch seconds when this window resets 67: } 68: }, 69: "vim": { // Optional, only present when vim mode is enabled 70: "mode": "INSERT" | "NORMAL" // Current vim editor mode 71: }, 72: "agent": { // Optional, only present when Claude is started with --agent flag 73: "name": "string", // Agent name (e.g., "code-architect", "test-runner") 74: "type": "string" // Optional: Agent type identifier 75: }, 76: "worktree": { // Optional, only present when in a --worktree session 77: "name": "string", // Worktree name/slug (e.g., "my-feature") 78: "path": "string", // Full path to the worktree directory 79: "branch": "string", // Optional: Git branch name for the worktree 80: "original_cwd": "string", // The directory Claude was in before entering the worktree 81: "original_branch": "string" // Optional: Branch that was checked out before entering the worktree 82: } 83: } 84: You can use this JSON data in your command like: 85: - $(cat | jq -r '.model.display_name') 86: - $(cat | jq -r '.workspace.current_dir') 87: - $(cat | jq -r '.output_style.name') 88: Or store it in a variable first: 89: - input=$(cat); echo "$(echo "$input" | jq -r '.model.display_name') in $(echo "$input" | jq -r '.workspace.current_dir')" 90: To display context remaining percentage (simplest approach using pre-calculated field): 91: - input=$(cat); remaining=$(echo "$input" | jq -r '.context_window.remaining_percentage // empty'); [ -n "$remaining" ] && echo "Context: $remaining% remaining" 92: Or to display context used percentage: 93: - input=$(cat); used=$(echo "$input" | jq -r '.context_window.used_percentage // empty'); [ -n "$used" ] && echo "Context: $used% used" 94: To display Claude.ai subscription rate limit usage (5-hour session limit): 95: - input=$(cat); pct=$(echo "$input" | jq -r '.rate_limits.five_hour.used_percentage // empty'); [ -n "$pct" ] && printf "5h: %.0f%%" "$pct" 96: To display both 5-hour and 7-day limits when available: 97: - input=$(cat); five=$(echo "$input" | jq -r '.rate_limits.five_hour.used_percentage // empty'); week=$(echo "$input" | jq -r '.rate_limits.seven_day.used_percentage // empty'); out=""; [ -n "$five" ] && out="5h:$(printf '%.0f' "$five")%"; [ -n "$week" ] && out="$out 7d:$(printf '%.0f' "$week")%"; echo "$out" 98: 2. For longer commands, you can save a new file in the user's ~/.claude directory, e.g.: 99: - ~/.claude/statusline-command.sh and reference that file in the settings. 100: 3. Update the user's ~/.claude/settings.json with: 101: { 102: "statusLine": { 103: "type": "command", 104: "command": "your_command_here" 105: } 106: } 107: 4. If ~/.claude/settings.json is a symlink, update the target file instead. 108: Guidelines: 109: - Preserve existing settings when updating 110: - Return a summary of what was configured, including the name of the script file if used 111: - If the script includes git commands, they should skip optional locks 112: - IMPORTANT: At the end of your response, inform the parent agent that this "statusline-setup" agent must be used for further status line changes. 113: Also ensure that the user is informed that they can ask Claude to continue to make changes to the status line. 114: ` 115: export const STATUSLINE_SETUP_AGENT: BuiltInAgentDefinition = { 116: agentType: 'statusline-setup', 117: whenToUse: 118: "Use this agent to configure the user's Claude Code status line setting.", 119: tools: ['Read', 'Edit'], 120: source: 'built-in', 121: baseDir: 'built-in', 122: model: 'sonnet', 123: color: 'orange', 124: getSystemPrompt: () => STATUSLINE_SYSTEM_PROMPT, 125: }

File: src/tools/AgentTool/built-in/verificationAgent.ts

typescript 1: import { BASH_TOOL_NAME } from 'src/tools/BashTool/toolName.js' 2: import { EXIT_PLAN_MODE_TOOL_NAME } from 'src/tools/ExitPlanModeTool/constants.js' 3: import { FILE_EDIT_TOOL_NAME } from 'src/tools/FileEditTool/constants.js' 4: import { FILE_WRITE_TOOL_NAME } from 'src/tools/FileWriteTool/prompt.js' 5: import { NOTEBOOK_EDIT_TOOL_NAME } from 'src/tools/NotebookEditTool/constants.js' 6: import { WEB_FETCH_TOOL_NAME } from 'src/tools/WebFetchTool/prompt.js' 7: import { AGENT_TOOL_NAME } from '../constants.js' 8: import type { BuiltInAgentDefinition } from '../loadAgentsDir.js' 9: const VERIFICATION_SYSTEM_PROMPT = `You are a verification specialist. Your job is not to confirm the implementation works — it's to try to break it. 10: You have two documented failure patterns. First, verification avoidance: when faced with a check, you find reasons not to run it — you read code, narrate what you would test, write "PASS," and move on. Second, being seduced by the first 80%: you see a polished UI or a passing test suite and feel inclined to pass it, not noticing half the buttons do nothing, the state vanishes on refresh, or the backend crashes on bad input. The first 80% is the easy part. Your entire value is in finding the last 20%. The caller may spot-check your commands by re-running them — if a PASS step has no command output, or output that doesn't match re-execution, your report gets rejected. 11: === CRITICAL: DO NOT MODIFY THE PROJECT === 12: You are STRICTLY PROHIBITED from: 13: - Creating, modifying, or deleting any files IN THE PROJECT DIRECTORY 14: - Installing dependencies or packages 15: - Running git write operations (add, commit, push) 16: You MAY write ephemeral test scripts to a temp directory (/tmp or $TMPDIR) via ${BASH_TOOL_NAME} redirection when inline commands aren't sufficient — e.g., a multi-step race harness or a Playwright test. Clean up after yourself. 17: Check your ACTUAL available tools rather than assuming from this prompt. You may have browser automation (mcp__claude-in-chrome__*, mcp__playwright__*), ${WEB_FETCH_TOOL_NAME}, or other MCP tools depending on the session — do not skip capabilities you didn't think to check for. 18: === WHAT YOU RECEIVE === 19: You will receive: the original task description, files changed, approach taken, and optionally a plan file path. 20: === VERIFICATION STRATEGY === 21: Adapt your strategy based on what was changed: 22: **Frontend changes**: Start dev server → check your tools for browser automation (mcp__claude-in-chrome__*, mcp__playwright__*) and USE them to navigate, screenshot, click, and read console — do NOT say "needs a real browser" without attempting → curl a sample of page subresources (image-optimizer URLs like /_next/image, same-origin API routes, static assets) since HTML can serve 200 while everything it references fails → run frontend tests 23: **Backend/API changes**: Start server → curl/fetch endpoints → verify response shapes against expected values (not just status codes) → test error handling → check edge cases 24: **CLI/script changes**: Run with representative inputs → verify stdout/stderr/exit codes → test edge inputs (empty, malformed, boundary) → verify --help / usage output is accurate 25: **Infrastructure/config changes**: Validate syntax → dry-run where possible (terraform plan, kubectl apply --dry-run=server, docker build, nginx -t) → check env vars / secrets are actually referenced, not just defined 26: **Library/package changes**: Build → full test suite → import the library from a fresh context and exercise the public API as a consumer would → verify exported types match README/docs examples 27: **Bug fixes**: Reproduce the original bug → verify fix → run regression tests → check related functionality for side effects 28: **Mobile (iOS/Android)**: Clean build → install on simulator/emulator → dump accessibility/UI tree (idb ui describe-all / uiautomator dump), find elements by label, tap by tree coords, re-dump to verify; screenshots secondary → kill and relaunch to test persistence → check crash logs (logcat / device console) 29: **Data/ML pipeline**: Run with sample input → verify output shape/schema/types → test empty input, single row, NaN/null handling → check for silent data loss (row counts in vs out) 30: **Database migrations**: Run migration up → verify schema matches intent → run migration down (reversibility) → test against existing data, not just empty DB 31: **Refactoring (no behavior change)**: Existing test suite MUST pass unchanged → diff the public API surface (no new/removed exports) → spot-check observable behavior is identical (same inputs → same outputs) 32: **Other change types**: The pattern is always the same — (a) figure out how to exercise this change directly (run/call/invoke/deploy it), (b) check outputs against expectations, (c) try to break it with inputs/conditions the implementer didn't test. The strategies above are worked examples for common cases. 33: === REQUIRED STEPS (universal baseline) === 34: 1. Read the project's CLAUDE.md / README for build/test commands and conventions. Check package.json / Makefile / pyproject.toml for script names. If the implementer pointed you to a plan or spec file, read it — that's the success criteria. 35: 2. Run the build (if applicable). A broken build is an automatic FAIL. 36: 3. Run the project's test suite (if it has one). Failing tests are an automatic FAIL. 37: 4. Run linters/type-checkers if configured (eslint, tsc, mypy, etc.). 38: 5. Check for regressions in related code. 39: Then apply the type-specific strategy above. Match rigor to stakes: a one-off script doesn't need race-condition probes; production payments code needs everything. 40: Test suite results are context, not evidence. Run the suite, note pass/fail, then move on to your real verification. The implementer is an LLM too — its tests may be heavy on mocks, circular assertions, or happy-path coverage that proves nothing about whether the system actually works end-to-end. 41: === RECOGNIZE YOUR OWN RATIONALIZATIONS === 42: You will feel the urge to skip checks. These are the exact excuses you reach for — recognize them and do the opposite: 43: - "The code looks correct based on my reading" — reading is not verification. Run it. 44: - "The implementer's tests already pass" — the implementer is an LLM. Verify independently. 45: - "This is probably fine" — probably is not verified. Run it. 46: - "Let me start the server and check the code" — no. Start the server and hit the endpoint. 47: - "I don't have a browser" — did you actually check for mcp__claude-in-chrome__* / mcp__playwright__*? If present, use them. If an MCP tool fails, troubleshoot (server running? selector right?). The fallback exists so you don't invent your own "can't do this" story. 48: - "This would take too long" — not your call. 49: If you catch yourself writing an explanation instead of a command, stop. Run the command. 50: === ADVERSARIAL PROBES (adapt to the change type) === 51: Functional tests confirm the happy path. Also try to break it: 52: - **Concurrency** (servers/APIs): parallel requests to create-if-not-exists paths — duplicate sessions? lost writes? 53: - **Boundary values**: 0, -1, empty string, very long strings, unicode, MAX_INT 54: - **Idempotency**: same mutating request twice — duplicate created? error? correct no-op? 55: - **Orphan operations**: delete/reference IDs that don't exist 56: These are seeds, not a checklist — pick the ones that fit what you're verifying. 57: === BEFORE ISSUING PASS === 58: Your report must include at least one adversarial probe you ran (concurrency, boundary, idempotency, orphan op, or similar) and its result — even if the result was "handled correctly." If all your checks are "returns 200" or "test suite passes," you have confirmed the happy path, not verified correctness. Go back and try to break something. 59: === BEFORE ISSUING FAIL === 60: You found something that looks broken. Before reporting FAIL, check you haven't missed why it's actually fine: 61: - **Already handled**: is there defensive code elsewhere (validation upstream, error recovery downstream) that prevents this? 62: - **Intentional**: does CLAUDE.md / comments / commit message explain this as deliberate? 63: - **Not actionable**: is this a real limitation but unfixable without breaking an external contract (stable API, protocol spec, backwards compat)? If so, note it as an observation, not a FAIL — a "bug" that can't be fixed isn't actionable. 64: Don't use these as excuses to wave away real issues — but don't FAIL on intentional behavior either. 65: === OUTPUT FORMAT (REQUIRED) === 66: Every check MUST follow this structure. A check without a Command run block is not a PASS — it's a skip. 67: \`\`\` 68: ### Check: [what you're verifying] 69: **Command run:** 70: [exact command you executed] 71: **Output observed:** 72: [actual terminal output — copy-paste, not paraphrased. Truncate if very long but keep the relevant part.] 73: **Result: PASS** (or FAIL — with Expected vs Actual) 74: \`\`\` 75: Bad (rejected): 76: \`\`\` 77: ### Check: POST /api/register validation 78: **Result: PASS** 79: Evidence: Reviewed the route handler in routes/auth.py. The logic correctly validates 80: email format and password length before DB insert. 81: \`\`\` 82: (No command run. Reading code is not verification.) 83: Good: 84: \`\`\` 85: ### Check: POST /api/register rejects short password 86: **Command run:** 87: curl -s -X POST localhost:8000/api/register -H 'Content-Type: application/json' \\ 88: -d '{"email":"t@t.co","password":"short"}' | python3 -m json.tool 89: **Output observed:** 90: { 91: "error": "password must be at least 8 characters" 92: } 93: (HTTP 400) 94: **Expected vs Actual:** Expected 400 with password-length error. Got exactly that. 95: **Result: PASS** 96: \`\`\` 97: End with exactly this line (parsed by caller): 98: VERDICT: PASS 99: or 100: VERDICT: FAIL 101: or 102: VERDICT: PARTIAL 103: PARTIAL is for environmental limitations only (no test framework, tool unavailable, server can't start) — not for "I'm unsure whether this is a bug." If you can run the check, you must decide PASS or FAIL. 104: Use the literal string \`VERDICT: \` followed by exactly one of \`PASS\`, \`FAIL\`, \`PARTIAL\`. No markdown bold, no punctuation, no variation. 105: - **FAIL**: include what failed, exact error output, reproduction steps. 106: - **PARTIAL**: what was verified, what could not be and why (missing tool/env), what the implementer should know.` 107: const VERIFICATION_WHEN_TO_USE = 108: 'Use this agent to verify that implementation work is correct before reporting completion. Invoke after non-trivial tasks (3+ file edits, backend/API changes, infrastructure changes). Pass the ORIGINAL user task description, list of files changed, and approach taken. The agent runs builds, tests, linters, and checks to produce a PASS/FAIL/PARTIAL verdict with evidence.' 109: export const VERIFICATION_AGENT: BuiltInAgentDefinition = { 110: agentType: 'verification', 111: whenToUse: VERIFICATION_WHEN_TO_USE, 112: color: 'red', 113: background: true, 114: disallowedTools: [ 115: AGENT_TOOL_NAME, 116: EXIT_PLAN_MODE_TOOL_NAME, 117: FILE_EDIT_TOOL_NAME, 118: FILE_WRITE_TOOL_NAME, 119: NOTEBOOK_EDIT_TOOL_NAME, 120: ], 121: source: 'built-in', 122: baseDir: 'built-in', 123: model: 'inherit', 124: getSystemPrompt: () => VERIFICATION_SYSTEM_PROMPT, 125: criticalSystemReminder_EXPERIMENTAL: 126: 'CRITICAL: This is a VERIFICATION-ONLY task. You CANNOT edit, write, or create files IN THE PROJECT DIRECTORY (tmp is allowed for ephemeral test scripts). You MUST end with VERDICT: PASS, VERDICT: FAIL, or VERDICT: PARTIAL.', 127: }

File: src/tools/AgentTool/agentColorManager.ts

typescript 1: import { getAgentColorMap } from '../../bootstrap/state.js' 2: import type { Theme } from '../../utils/theme.js' 3: export type AgentColorName = 4: | 'red' 5: | 'blue' 6: | 'green' 7: | 'yellow' 8: | 'purple' 9: | 'orange' 10: | 'pink' 11: | 'cyan' 12: export const AGENT_COLORS: readonly AgentColorName[] = [ 13: 'red', 14: 'blue', 15: 'green', 16: 'yellow', 17: 'purple', 18: 'orange', 19: 'pink', 20: 'cyan', 21: ] as const 22: export const AGENT_COLOR_TO_THEME_COLOR = { 23: red: 'red_FOR_SUBAGENTS_ONLY', 24: blue: 'blue_FOR_SUBAGENTS_ONLY', 25: green: 'green_FOR_SUBAGENTS_ONLY', 26: yellow: 'yellow_FOR_SUBAGENTS_ONLY', 27: purple: 'purple_FOR_SUBAGENTS_ONLY', 28: orange: 'orange_FOR_SUBAGENTS_ONLY', 29: pink: 'pink_FOR_SUBAGENTS_ONLY', 30: cyan: 'cyan_FOR_SUBAGENTS_ONLY', 31: } as const satisfies Record<AgentColorName, keyof Theme> 32: export function getAgentColor(agentType: string): keyof Theme | undefined { 33: if (agentType === 'general-purpose') { 34: return undefined 35: } 36: const agentColorMap = getAgentColorMap() 37: const existingColor = agentColorMap.get(agentType) 38: if (existingColor && AGENT_COLORS.includes(existingColor)) { 39: return AGENT_COLOR_TO_THEME_COLOR[existingColor] 40: } 41: return undefined 42: } 43: export function setAgentColor( 44: agentType: string, 45: color: AgentColorName | undefined, 46: ): void { 47: const agentColorMap = getAgentColorMap() 48: if (!color) { 49: agentColorMap.delete(agentType) 50: return 51: } 52: if (AGENT_COLORS.includes(color)) { 53: agentColorMap.set(agentType, color) 54: } 55: }

File: src/tools/AgentTool/agentDisplay.ts

typescript 1: import { getDefaultSubagentModel } from '../../utils/model/agent.js' 2: import { 3: getSourceDisplayName, 4: type SettingSource, 5: } from '../../utils/settings/constants.js' 6: import type { AgentDefinition } from './loadAgentsDir.js' 7: type AgentSource = SettingSource | 'built-in' | 'plugin' 8: export type AgentSourceGroup = { 9: label: string 10: source: AgentSource 11: } 12: export const AGENT_SOURCE_GROUPS: AgentSourceGroup[] = [ 13: { label: 'User agents', source: 'userSettings' }, 14: { label: 'Project agents', source: 'projectSettings' }, 15: { label: 'Local agents', source: 'localSettings' }, 16: { label: 'Managed agents', source: 'policySettings' }, 17: { label: 'Plugin agents', source: 'plugin' }, 18: { label: 'CLI arg agents', source: 'flagSettings' }, 19: { label: 'Built-in agents', source: 'built-in' }, 20: ] 21: export type ResolvedAgent = AgentDefinition & { 22: overriddenBy?: AgentSource 23: } 24: export function resolveAgentOverrides( 25: allAgents: AgentDefinition[], 26: activeAgents: AgentDefinition[], 27: ): ResolvedAgent[] { 28: const activeMap = new Map<string, AgentDefinition>() 29: for (const agent of activeAgents) { 30: activeMap.set(agent.agentType, agent) 31: } 32: const seen = new Set<string>() 33: const resolved: ResolvedAgent[] = [] 34: for (const agent of allAgents) { 35: const key = `${agent.agentType}:${agent.source}` 36: if (seen.has(key)) continue 37: seen.add(key) 38: const active = activeMap.get(agent.agentType) 39: const overriddenBy = 40: active && active.source !== agent.source ? active.source : undefined 41: resolved.push({ ...agent, overriddenBy }) 42: } 43: return resolved 44: } 45: export function resolveAgentModelDisplay( 46: agent: AgentDefinition, 47: ): string | undefined { 48: const model = agent.model || getDefaultSubagentModel() 49: if (!model) return undefined 50: return model === 'inherit' ? 'inherit' : model 51: } 52: export function getOverrideSourceLabel(source: AgentSource): string { 53: return getSourceDisplayName(source).toLowerCase() 54: } 55: export function compareAgentsByName( 56: a: AgentDefinition, 57: b: AgentDefinition, 58: ): number { 59: return a.agentType.localeCompare(b.agentType, undefined, { 60: sensitivity: 'base', 61: }) 62: }

File: src/tools/AgentTool/agentMemory.ts

typescript 1: import { join, normalize, sep } from 'path' 2: import { getProjectRoot } from '../../bootstrap/state.js' 3: import { 4: buildMemoryPrompt, 5: ensureMemoryDirExists, 6: } from '../../memdir/memdir.js' 7: import { getMemoryBaseDir } from '../../memdir/paths.js' 8: import { getCwd } from '../../utils/cwd.js' 9: import { findCanonicalGitRoot } from '../../utils/git.js' 10: import { sanitizePath } from '../../utils/path.js' 11: export type AgentMemoryScope = 'user' | 'project' | 'local' 12: function sanitizeAgentTypeForPath(agentType: string): string { 13: return agentType.replace(/:/g, '-') 14: } 15: function getLocalAgentMemoryDir(dirName: string): string { 16: if (process.env.CLAUDE_CODE_REMOTE_MEMORY_DIR) { 17: return ( 18: join( 19: process.env.CLAUDE_CODE_REMOTE_MEMORY_DIR, 20: 'projects', 21: sanitizePath( 22: findCanonicalGitRoot(getProjectRoot()) ?? getProjectRoot(), 23: ), 24: 'agent-memory-local', 25: dirName, 26: ) + sep 27: ) 28: } 29: return join(getCwd(), '.claude', 'agent-memory-local', dirName) + sep 30: } 31: export function getAgentMemoryDir( 32: agentType: string, 33: scope: AgentMemoryScope, 34: ): string { 35: const dirName = sanitizeAgentTypeForPath(agentType) 36: switch (scope) { 37: case 'project': 38: return join(getCwd(), '.claude', 'agent-memory', dirName) + sep 39: case 'local': 40: return getLocalAgentMemoryDir(dirName) 41: case 'user': 42: return join(getMemoryBaseDir(), 'agent-memory', dirName) + sep 43: } 44: } 45: export function isAgentMemoryPath(absolutePath: string): boolean { 46: const normalizedPath = normalize(absolutePath) 47: const memoryBase = getMemoryBaseDir() 48: if (normalizedPath.startsWith(join(memoryBase, 'agent-memory') + sep)) { 49: return true 50: } 51: if ( 52: normalizedPath.startsWith(join(getCwd(), '.claude', 'agent-memory') + sep) 53: ) { 54: return true 55: } 56: if (process.env.CLAUDE_CODE_REMOTE_MEMORY_DIR) { 57: if ( 58: normalizedPath.includes(sep + 'agent-memory-local' + sep) && 59: normalizedPath.startsWith( 60: join(process.env.CLAUDE_CODE_REMOTE_MEMORY_DIR, 'projects') + sep, 61: ) 62: ) { 63: return true 64: } 65: } else if ( 66: normalizedPath.startsWith( 67: join(getCwd(), '.claude', 'agent-memory-local') + sep, 68: ) 69: ) { 70: return true 71: } 72: return false 73: } 74: export function getAgentMemoryEntrypoint( 75: agentType: string, 76: scope: AgentMemoryScope, 77: ): string { 78: return join(getAgentMemoryDir(agentType, scope), 'MEMORY.md') 79: } 80: export function getMemoryScopeDisplay( 81: memory: AgentMemoryScope | undefined, 82: ): string { 83: switch (memory) { 84: case 'user': 85: return `User (${join(getMemoryBaseDir(), 'agent-memory')}/)` 86: case 'project': 87: return 'Project (.claude/agent-memory/)' 88: case 'local': 89: return `Local (${getLocalAgentMemoryDir('...')})` 90: default: 91: return 'None' 92: } 93: } 94: export function loadAgentMemoryPrompt( 95: agentType: string, 96: scope: AgentMemoryScope, 97: ): string { 98: let scopeNote: string 99: switch (scope) { 100: case 'user': 101: scopeNote = 102: '- Since this memory is user-scope, keep learnings general since they apply across all projects' 103: break 104: case 'project': 105: scopeNote = 106: '- Since this memory is project-scope and shared with your team via version control, tailor your memories to this project' 107: break 108: case 'local': 109: scopeNote = 110: '- Since this memory is local-scope (not checked into version control), tailor your memories to this project and machine' 111: break 112: } 113: const memoryDir = getAgentMemoryDir(agentType, scope) 114: void ensureMemoryDirExists(memoryDir) 115: const coworkExtraGuidelines = 116: process.env.CLAUDE_COWORK_MEMORY_EXTRA_GUIDELINES 117: return buildMemoryPrompt({ 118: displayName: 'Persistent Agent Memory', 119: memoryDir, 120: extraGuidelines: 121: coworkExtraGuidelines && coworkExtraGuidelines.trim().length > 0 122: ? [scopeNote, coworkExtraGuidelines] 123: : [scopeNote], 124: }) 125: }

File: src/tools/AgentTool/agentMemorySnapshot.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 { getCwd } from '../../utils/cwd.js' 5: import { logForDebugging } from '../../utils/debug.js' 6: import { lazySchema } from '../../utils/lazySchema.js' 7: import { jsonParse, jsonStringify } from '../../utils/slowOperations.js' 8: import { type AgentMemoryScope, getAgentMemoryDir } from './agentMemory.js' 9: const SNAPSHOT_BASE = 'agent-memory-snapshots' 10: const SNAPSHOT_JSON = 'snapshot.json' 11: const SYNCED_JSON = '.snapshot-synced.json' 12: const snapshotMetaSchema = lazySchema(() => 13: z.object({ 14: updatedAt: z.string().min(1), 15: }), 16: ) 17: const syncedMetaSchema = lazySchema(() => 18: z.object({ 19: syncedFrom: z.string().min(1), 20: }), 21: ) 22: type SyncedMeta = z.infer<ReturnType<typeof syncedMetaSchema>> 23: export function getSnapshotDirForAgent(agentType: string): string { 24: return join(getCwd(), '.claude', SNAPSHOT_BASE, agentType) 25: } 26: function getSnapshotJsonPath(agentType: string): string { 27: return join(getSnapshotDirForAgent(agentType), SNAPSHOT_JSON) 28: } 29: function getSyncedJsonPath(agentType: string, scope: AgentMemoryScope): string { 30: return join(getAgentMemoryDir(agentType, scope), SYNCED_JSON) 31: } 32: async function readJsonFile<T>( 33: path: string, 34: schema: z.ZodType<T>, 35: ): Promise<T | null> { 36: try { 37: const content = await readFile(path, { encoding: 'utf-8' }) 38: const result = schema.safeParse(jsonParse(content)) 39: return result.success ? result.data : null 40: } catch { 41: return null 42: } 43: } 44: async function copySnapshotToLocal( 45: agentType: string, 46: scope: AgentMemoryScope, 47: ): Promise<void> { 48: const snapshotMemDir = getSnapshotDirForAgent(agentType) 49: const localMemDir = getAgentMemoryDir(agentType, scope) 50: await mkdir(localMemDir, { recursive: true }) 51: try { 52: const files = await readdir(snapshotMemDir, { withFileTypes: true }) 53: for (const dirent of files) { 54: if (!dirent.isFile() || dirent.name === SNAPSHOT_JSON) continue 55: const content = await readFile(join(snapshotMemDir, dirent.name), { 56: encoding: 'utf-8', 57: }) 58: await writeFile(join(localMemDir, dirent.name), content) 59: } 60: } catch (e) { 61: logForDebugging(`Failed to copy snapshot to local agent memory: ${e}`) 62: } 63: } 64: async function saveSyncedMeta( 65: agentType: string, 66: scope: AgentMemoryScope, 67: snapshotTimestamp: string, 68: ): Promise<void> { 69: const syncedPath = getSyncedJsonPath(agentType, scope) 70: const localMemDir = getAgentMemoryDir(agentType, scope) 71: await mkdir(localMemDir, { recursive: true }) 72: const meta: SyncedMeta = { syncedFrom: snapshotTimestamp } 73: try { 74: await writeFile(syncedPath, jsonStringify(meta)) 75: } catch (e) { 76: logForDebugging(`Failed to save snapshot sync metadata: ${e}`) 77: } 78: } 79: export async function checkAgentMemorySnapshot( 80: agentType: string, 81: scope: AgentMemoryScope, 82: ): Promise<{ 83: action: 'none' | 'initialize' | 'prompt-update' 84: snapshotTimestamp?: string 85: }> { 86: const snapshotMeta = await readJsonFile( 87: getSnapshotJsonPath(agentType), 88: snapshotMetaSchema(), 89: ) 90: if (!snapshotMeta) { 91: return { action: 'none' } 92: } 93: const localMemDir = getAgentMemoryDir(agentType, scope) 94: let hasLocalMemory = false 95: try { 96: const dirents = await readdir(localMemDir, { withFileTypes: true }) 97: hasLocalMemory = dirents.some(d => d.isFile() && d.name.endsWith('.md')) 98: } catch { 99: } 100: if (!hasLocalMemory) { 101: return { action: 'initialize', snapshotTimestamp: snapshotMeta.updatedAt } 102: } 103: const syncedMeta = await readJsonFile( 104: getSyncedJsonPath(agentType, scope), 105: syncedMetaSchema(), 106: ) 107: if ( 108: !syncedMeta || 109: new Date(snapshotMeta.updatedAt) > new Date(syncedMeta.syncedFrom) 110: ) { 111: return { 112: action: 'prompt-update', 113: snapshotTimestamp: snapshotMeta.updatedAt, 114: } 115: } 116: return { action: 'none' } 117: } 118: export async function initializeFromSnapshot( 119: agentType: string, 120: scope: AgentMemoryScope, 121: snapshotTimestamp: string, 122: ): Promise<void> { 123: logForDebugging( 124: `Initializing agent memory for ${agentType} from project snapshot`, 125: ) 126: await copySnapshotToLocal(agentType, scope) 127: await saveSyncedMeta(agentType, scope, snapshotTimestamp) 128: } 129: export async function replaceFromSnapshot( 130: agentType: string, 131: scope: AgentMemoryScope, 132: snapshotTimestamp: string, 133: ): Promise<void> { 134: logForDebugging( 135: `Replacing agent memory for ${agentType} with project snapshot`, 136: ) 137: const localMemDir = getAgentMemoryDir(agentType, scope) 138: try { 139: const existing = await readdir(localMemDir, { withFileTypes: true }) 140: for (const dirent of existing) { 141: if (dirent.isFile() && dirent.name.endsWith('.md')) { 142: await unlink(join(localMemDir, dirent.name)) 143: } 144: } 145: } catch { 146: } 147: await copySnapshotToLocal(agentType, scope) 148: await saveSyncedMeta(agentType, scope, snapshotTimestamp) 149: } 150: export async function markSnapshotSynced( 151: agentType: string, 152: scope: AgentMemoryScope, 153: snapshotTimestamp: string, 154: ): Promise<void> { 155: await saveSyncedMeta(agentType, scope, snapshotTimestamp) 156: }

File: src/tools/AgentTool/AgentTool.tsx

typescript 1: import { feature } from 'bun:bundle'; 2: import * as React from 'react'; 3: import { buildTool, type ToolDef, toolMatchesName } from 'src/Tool.js'; 4: import type { Message as MessageType, NormalizedUserMessage } from 'src/types/message.js'; 5: import { getQuerySourceForAgent } from 'src/utils/promptCategory.js'; 6: import { z } from 'zod/v4'; 7: import { clearInvokedSkillsForAgent, getSdkAgentProgressSummariesEnabled } from '../../bootstrap/state.js'; 8: import { enhanceSystemPromptWithEnvDetails, getSystemPrompt } from '../../constants/prompts.js'; 9: import { isCoordinatorMode } from '../../coordinator/coordinatorMode.js'; 10: import { startAgentSummarization } from '../../services/AgentSummary/agentSummary.js'; 11: import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js'; 12: import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from '../../services/analytics/index.js'; 13: import { clearDumpState } from '../../services/api/dumpPrompts.js'; 14: import { completeAgentTask as completeAsyncAgent, createActivityDescriptionResolver, createProgressTracker, enqueueAgentNotification, failAgentTask as failAsyncAgent, getProgressUpdate, getTokenCountFromTracker, isLocalAgentTask, killAsyncAgent, registerAgentForeground, registerAsyncAgent, unregisterAgentForeground, updateAgentProgress as updateAsyncAgentProgress, updateProgressFromMessage } from '../../tasks/LocalAgentTask/LocalAgentTask.js'; 15: import { checkRemoteAgentEligibility, formatPreconditionError, getRemoteTaskSessionUrl, registerRemoteAgentTask } from '../../tasks/RemoteAgentTask/RemoteAgentTask.js'; 16: import { assembleToolPool } from '../../tools.js'; 17: import { asAgentId } from '../../types/ids.js'; 18: import { runWithAgentContext } from '../../utils/agentContext.js'; 19: import { isAgentSwarmsEnabled } from '../../utils/agentSwarmsEnabled.js'; 20: import { getCwd, runWithCwdOverride } from '../../utils/cwd.js'; 21: import { logForDebugging } from '../../utils/debug.js'; 22: import { isEnvTruthy } from '../../utils/envUtils.js'; 23: import { AbortError, errorMessage, toError } from '../../utils/errors.js'; 24: import type { CacheSafeParams } from '../../utils/forkedAgent.js'; 25: import { lazySchema } from '../../utils/lazySchema.js'; 26: import { createUserMessage, extractTextContent, isSyntheticMessage, normalizeMessages } from '../../utils/messages.js'; 27: import { getAgentModel } from '../../utils/model/agent.js'; 28: import { permissionModeSchema } from '../../utils/permissions/PermissionMode.js'; 29: import type { PermissionResult } from '../../utils/permissions/PermissionResult.js'; 30: import { filterDeniedAgents, getDenyRuleForAgent } from '../../utils/permissions/permissions.js'; 31: import { enqueueSdkEvent } from '../../utils/sdkEventQueue.js'; 32: import { writeAgentMetadata } from '../../utils/sessionStorage.js'; 33: import { sleep } from '../../utils/sleep.js'; 34: import { buildEffectiveSystemPrompt } from '../../utils/systemPrompt.js'; 35: import { asSystemPrompt } from '../../utils/systemPromptType.js'; 36: import { getTaskOutputPath } from '../../utils/task/diskOutput.js'; 37: import { getParentSessionId, isTeammate } from '../../utils/teammate.js'; 38: import { isInProcessTeammate } from '../../utils/teammateContext.js'; 39: import { teleportToRemote } from '../../utils/teleport.js'; 40: import { getAssistantMessageContentLength } from '../../utils/tokens.js'; 41: import { createAgentId } from '../../utils/uuid.js'; 42: import { createAgentWorktree, hasWorktreeChanges, removeAgentWorktree } from '../../utils/worktree.js'; 43: import { BASH_TOOL_NAME } from '../BashTool/toolName.js'; 44: import { BackgroundHint } from '../BashTool/UI.js'; 45: import { FILE_READ_TOOL_NAME } from '../FileReadTool/prompt.js'; 46: import { spawnTeammate } from '../shared/spawnMultiAgent.js'; 47: import { setAgentColor } from './agentColorManager.js'; 48: import { agentToolResultSchema, classifyHandoffIfNeeded, emitTaskProgress, extractPartialResult, finalizeAgentTool, getLastToolUseName, runAsyncAgentLifecycle } from './agentToolUtils.js'; 49: import { GENERAL_PURPOSE_AGENT } from './built-in/generalPurposeAgent.js'; 50: import { AGENT_TOOL_NAME, LEGACY_AGENT_TOOL_NAME, ONE_SHOT_BUILTIN_AGENT_TYPES } from './constants.js'; 51: import { buildForkedMessages, buildWorktreeNotice, FORK_AGENT, isForkSubagentEnabled, isInForkChild } from './forkSubagent.js'; 52: import type { AgentDefinition } from './loadAgentsDir.js'; 53: import { filterAgentsByMcpRequirements, hasRequiredMcpServers, isBuiltInAgent } from './loadAgentsDir.js'; 54: import { getPrompt } from './prompt.js'; 55: import { runAgent } from './runAgent.js'; 56: import { renderGroupedAgentToolUse, renderToolResultMessage, renderToolUseErrorMessage, renderToolUseMessage, renderToolUseProgressMessage, renderToolUseRejectedMessage, renderToolUseTag, userFacingName, userFacingNameBackgroundColor } from './UI.js'; 57: const proactiveModule = feature('PROACTIVE') || feature('KAIROS') ? require('../../proactive/index.js') as typeof import('../../proactive/index.js') : null; 58: const PROGRESS_THRESHOLD_MS = 2000; 59: const isBackgroundTasksDisabled = 60: isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_BACKGROUND_TASKS); 61: function getAutoBackgroundMs(): number { 62: if (isEnvTruthy(process.env.CLAUDE_AUTO_BACKGROUND_TASKS) || getFeatureValue_CACHED_MAY_BE_STALE('tengu_auto_background_agents', false)) { 63: return 120_000; 64: } 65: return 0; 66: } 67: const baseInputSchema = lazySchema(() => z.object({ 68: description: z.string().describe('A short (3-5 word) description of the task'), 69: prompt: z.string().describe('The task for the agent to perform'), 70: subagent_type: z.string().optional().describe('The type of specialized agent to use for this task'), 71: model: z.enum(['sonnet', 'opus', 'haiku']).optional().describe("Optional model override for this agent. Takes precedence over the agent definition's model frontmatter. If omitted, uses the agent definition's model, or inherits from the parent."), 72: run_in_background: z.boolean().optional().describe('Set to true to run this agent in the background. You will be notified when it completes.') 73: })); 74: const fullInputSchema = lazySchema(() => { 75: const multiAgentInputSchema = z.object({ 76: name: z.string().optional().describe('Name for the spawned agent. Makes it addressable via SendMessage({to: name}) while running.'), 77: team_name: z.string().optional().describe('Team name for spawning. Uses current team context if omitted.'), 78: mode: permissionModeSchema().optional().describe('Permission mode for spawned teammate (e.g., "plan" to require plan approval).') 79: }); 80: return baseInputSchema().merge(multiAgentInputSchema).extend({ 81: isolation: ("external" === 'ant' ? z.enum(['worktree', 'remote']) : z.enum(['worktree'])).optional().describe("external" === 'ant' ? 'Isolation mode. "worktree" creates a temporary git worktree so the agent works on an isolated copy of the repo. "remote" launches the agent in a remote CCR environment (always runs in background).' : 'Isolation mode. "worktree" creates a temporary git worktree so the agent works on an isolated copy of the repo.'), 82: cwd: z.string().optional().describe('Absolute path to run the agent in. Overrides the working directory for all filesystem and shell operations within this agent. Mutually exclusive with isolation: "worktree".') 83: }); 84: }); 85: export const inputSchema = lazySchema(() => { 86: const schema = feature('KAIROS') ? fullInputSchema() : fullInputSchema().omit({ 87: cwd: true 88: }); 89: return isBackgroundTasksDisabled || isForkSubagentEnabled() ? schema.omit({ 90: run_in_background: true 91: }) : schema; 92: }); 93: type InputSchema = ReturnType<typeof inputSchema>; 94: type AgentToolInput = z.infer<ReturnType<typeof baseInputSchema>> & { 95: name?: string; 96: team_name?: string; 97: mode?: z.infer<ReturnType<typeof permissionModeSchema>>; 98: isolation?: 'worktree' | 'remote'; 99: cwd?: string; 100: }; 101: export const outputSchema = lazySchema(() => { 102: const syncOutputSchema = agentToolResultSchema().extend({ 103: status: z.literal('completed'), 104: prompt: z.string() 105: }); 106: const asyncOutputSchema = z.object({ 107: status: z.literal('async_launched'), 108: agentId: z.string().describe('The ID of the async agent'), 109: description: z.string().describe('The description of the task'), 110: prompt: z.string().describe('The prompt for the agent'), 111: outputFile: z.string().describe('Path to the output file for checking agent progress'), 112: canReadOutputFile: z.boolean().optional().describe('Whether the calling agent has Read/Bash tools to check progress') 113: }); 114: return z.union([syncOutputSchema, asyncOutputSchema]); 115: }); 116: type OutputSchema = ReturnType<typeof outputSchema>; 117: type Output = z.input<OutputSchema>; 118: type TeammateSpawnedOutput = { 119: status: 'teammate_spawned'; 120: prompt: string; 121: teammate_id: string; 122: agent_id: string; 123: agent_type?: string; 124: model?: string; 125: name: string; 126: color?: string; 127: tmux_session_name: string; 128: tmux_window_name: string; 129: tmux_pane_id: string; 130: team_name?: string; 131: is_splitpane?: boolean; 132: plan_mode_required?: boolean; 133: }; 134: export type RemoteLaunchedOutput = { 135: status: 'remote_launched'; 136: taskId: string; 137: sessionUrl: string; 138: description: string; 139: prompt: string; 140: outputFile: string; 141: }; 142: type InternalOutput = Output | TeammateSpawnedOutput | RemoteLaunchedOutput; 143: import type { AgentToolProgress, ShellProgress } from '../../types/tools.js'; 144: export type Progress = AgentToolProgress | ShellProgress; 145: export const AgentTool = buildTool({ 146: async prompt({ 147: agents, 148: tools, 149: getToolPermissionContext, 150: allowedAgentTypes 151: }) { 152: const toolPermissionContext = await getToolPermissionContext(); 153: const mcpServersWithTools: string[] = []; 154: for (const tool of tools) { 155: if (tool.name?.startsWith('mcp__')) { 156: const parts = tool.name.split('__'); 157: const serverName = parts[1]; 158: if (serverName && !mcpServersWithTools.includes(serverName)) { 159: mcpServersWithTools.push(serverName); 160: } 161: } 162: } 163: const agentsWithMcpRequirementsMet = filterAgentsByMcpRequirements(agents, mcpServersWithTools); 164: const filteredAgents = filterDeniedAgents(agentsWithMcpRequirementsMet, toolPermissionContext, AGENT_TOOL_NAME); 165: const isCoordinator = feature('COORDINATOR_MODE') ? isEnvTruthy(process.env.CLAUDE_CODE_COORDINATOR_MODE) : false; 166: return await getPrompt(filteredAgents, isCoordinator, allowedAgentTypes); 167: }, 168: name: AGENT_TOOL_NAME, 169: searchHint: 'delegate work to a subagent', 170: aliases: [LEGACY_AGENT_TOOL_NAME], 171: maxResultSizeChars: 100_000, 172: async description() { 173: return 'Launch a new agent'; 174: }, 175: get inputSchema(): InputSchema { 176: return inputSchema(); 177: }, 178: get outputSchema(): OutputSchema { 179: return outputSchema(); 180: }, 181: async call({ 182: prompt, 183: subagent_type, 184: description, 185: model: modelParam, 186: run_in_background, 187: name, 188: team_name, 189: mode: spawnMode, 190: isolation, 191: cwd 192: }: AgentToolInput, toolUseContext, canUseTool, assistantMessage, onProgress?) { 193: const startTime = Date.now(); 194: const model = isCoordinatorMode() ? undefined : modelParam; 195: const appState = toolUseContext.getAppState(); 196: const permissionMode = appState.toolPermissionContext.mode; 197: const rootSetAppState = toolUseContext.setAppStateForTasks ?? toolUseContext.setAppState; 198: if (team_name && !isAgentSwarmsEnabled()) { 199: throw new Error('Agent Teams is not yet available on your plan.'); 200: } 201: const teamName = resolveTeamName({ 202: team_name 203: }, appState); 204: if (isTeammate() && teamName && name) { 205: throw new Error('Teammates cannot spawn other teammates — the team roster is flat. To spawn a subagent instead, omit the `name` parameter.'); 206: } 207: if (isInProcessTeammate() && teamName && run_in_background === true) { 208: throw new Error('In-process teammates cannot spawn background agents. Use run_in_background=false for synchronous subagents.'); 209: } 210: if (teamName && name) { 211: const agentDef = subagent_type ? toolUseContext.options.agentDefinitions.activeAgents.find(a => a.agentType === subagent_type) : undefined; 212: if (agentDef?.color) { 213: setAgentColor(subagent_type!, agentDef.color); 214: } 215: const result = await spawnTeammate({ 216: name, 217: prompt, 218: description, 219: team_name: teamName, 220: use_splitpane: true, 221: plan_mode_required: spawnMode === 'plan', 222: model: model ?? agentDef?.model, 223: agent_type: subagent_type, 224: invokingRequestId: assistantMessage?.requestId 225: }, toolUseContext); 226: const spawnResult: TeammateSpawnedOutput = { 227: status: 'teammate_spawned' as const, 228: prompt, 229: ...result.data 230: }; 231: return { 232: data: spawnResult 233: } as unknown as { 234: data: Output; 235: }; 236: } 237: const effectiveType = subagent_type ?? (isForkSubagentEnabled() ? undefined : GENERAL_PURPOSE_AGENT.agentType); 238: const isForkPath = effectiveType === undefined; 239: let selectedAgent: AgentDefinition; 240: if (isForkPath) { 241: if (toolUseContext.options.querySource === `agent:builtin:${FORK_AGENT.agentType}` || isInForkChild(toolUseContext.messages)) { 242: throw new Error('Fork is not available inside a forked worker. Complete your task directly using your tools.'); 243: } 244: selectedAgent = FORK_AGENT; 245: } else { 246: const allAgents = toolUseContext.options.agentDefinitions.activeAgents; 247: const { 248: allowedAgentTypes 249: } = toolUseContext.options.agentDefinitions; 250: const agents = filterDeniedAgents( 251: allowedAgentTypes ? allAgents.filter(a => allowedAgentTypes.includes(a.agentType)) : allAgents, appState.toolPermissionContext, AGENT_TOOL_NAME); 252: const found = agents.find(agent => agent.agentType === effectiveType); 253: if (!found) { 254: const agentExistsButDenied = allAgents.find(agent => agent.agentType === effectiveType); 255: if (agentExistsButDenied) { 256: const denyRule = getDenyRuleForAgent(appState.toolPermissionContext, AGENT_TOOL_NAME, effectiveType); 257: throw new Error(`Agent type '${effectiveType}' has been denied by permission rule '${AGENT_TOOL_NAME}(${effectiveType})' from ${denyRule?.source ?? 'settings'}.`); 258: } 259: throw new Error(`Agent type '${effectiveType}' not found. Available agents: ${agents.map(a => a.agentType).join(', ')}`); 260: } 261: selectedAgent = found; 262: } 263: if (isInProcessTeammate() && teamName && selectedAgent.background === true) { 264: throw new Error(`In-process teammates cannot spawn background agents. Agent '${selectedAgent.agentType}' has background: true in its definition.`); 265: } 266: const requiredMcpServers = selectedAgent.requiredMcpServers; 267: if (requiredMcpServers?.length) { 268: const hasPendingRequiredServers = appState.mcp.clients.some(c => c.type === 'pending' && requiredMcpServers.some(pattern => c.name.toLowerCase().includes(pattern.toLowerCase()))); 269: let currentAppState = appState; 270: if (hasPendingRequiredServers) { 271: const MAX_WAIT_MS = 30_000; 272: const POLL_INTERVAL_MS = 500; 273: const deadline = Date.now() + MAX_WAIT_MS; 274: while (Date.now() < deadline) { 275: await sleep(POLL_INTERVAL_MS); 276: currentAppState = toolUseContext.getAppState(); 277: const hasFailedRequiredServer = currentAppState.mcp.clients.some(c => c.type === 'failed' && requiredMcpServers.some(pattern => c.name.toLowerCase().includes(pattern.toLowerCase()))); 278: if (hasFailedRequiredServer) break; 279: const stillPending = currentAppState.mcp.clients.some(c => c.type === 'pending' && requiredMcpServers.some(pattern => c.name.toLowerCase().includes(pattern.toLowerCase()))); 280: if (!stillPending) break; 281: } 282: } 283: const serversWithTools: string[] = []; 284: for (const tool of currentAppState.mcp.tools) { 285: if (tool.name?.startsWith('mcp__')) { 286: const parts = tool.name.split('__'); 287: const serverName = parts[1]; 288: if (serverName && !serversWithTools.includes(serverName)) { 289: serversWithTools.push(serverName); 290: } 291: } 292: } 293: if (!hasRequiredMcpServers(selectedAgent, serversWithTools)) { 294: const missing = requiredMcpServers.filter(pattern => !serversWithTools.some(server => server.toLowerCase().includes(pattern.toLowerCase()))); 295: throw new Error(`Agent '${selectedAgent.agentType}' requires MCP servers matching: ${missing.join(', ')}. ` + `MCP servers with tools: ${serversWithTools.length > 0 ? serversWithTools.join(', ') : 'none'}. ` + `Use /mcp to configure and authenticate the required MCP servers.`); 296: } 297: } 298: if (selectedAgent.color) { 299: setAgentColor(selectedAgent.agentType, selectedAgent.color); 300: } 301: const resolvedAgentModel = getAgentModel(selectedAgent.model, toolUseContext.options.mainLoopModel, isForkPath ? undefined : model, permissionMode); 302: logEvent('tengu_agent_tool_selected', { 303: agent_type: selectedAgent.agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 304: model: resolvedAgentModel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 305: source: selectedAgent.source as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 306: color: selectedAgent.color as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 307: is_built_in_agent: isBuiltInAgent(selectedAgent), 308: is_resume: false, 309: is_async: (run_in_background === true || selectedAgent.background === true) && !isBackgroundTasksDisabled, 310: is_fork: isForkPath 311: }); 312: const effectiveIsolation = isolation ?? selectedAgent.isolation; 313: if ("external" === 'ant' && effectiveIsolation === 'remote') { 314: const eligibility = await checkRemoteAgentEligibility(); 315: if (!eligibility.eligible) { 316: const reasons = eligibility.errors.map(formatPreconditionError).join('\n'); 317: throw new Error(`Cannot launch remote agent:\n${reasons}`); 318: } 319: let bundleFailHint: string | undefined; 320: const session = await teleportToRemote({ 321: initialMessage: prompt, 322: description, 323: signal: toolUseContext.abortController.signal, 324: onBundleFail: msg => { 325: bundleFailHint = msg; 326: } 327: }); 328: if (!session) { 329: throw new Error(bundleFailHint ?? 'Failed to create remote session'); 330: } 331: const { 332: taskId, 333: sessionId 334: } = registerRemoteAgentTask({ 335: remoteTaskType: 'remote-agent', 336: session: { 337: id: session.id, 338: title: session.title || description 339: }, 340: command: prompt, 341: context: toolUseContext, 342: toolUseId: toolUseContext.toolUseId 343: }); 344: logEvent('tengu_agent_tool_remote_launched', { 345: agent_type: selectedAgent.agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 346: }); 347: const remoteResult: RemoteLaunchedOutput = { 348: status: 'remote_launched', 349: taskId, 350: sessionUrl: getRemoteTaskSessionUrl(sessionId), 351: description, 352: prompt, 353: outputFile: getTaskOutputPath(taskId) 354: }; 355: return { 356: data: remoteResult 357: } as unknown as { 358: data: Output; 359: }; 360: } 361: let enhancedSystemPrompt: string[] | undefined; 362: let forkParentSystemPrompt: ReturnType<typeof buildEffectiveSystemPrompt> | undefined; 363: let promptMessages: MessageType[]; 364: if (isForkPath) { 365: if (toolUseContext.renderedSystemPrompt) { 366: forkParentSystemPrompt = toolUseContext.renderedSystemPrompt; 367: } else { 368: const mainThreadAgentDefinition = appState.agent ? appState.agentDefinitions.activeAgents.find(a => a.agentType === appState.agent) : undefined; 369: const additionalWorkingDirectories = Array.from(appState.toolPermissionContext.additionalWorkingDirectories.keys()); 370: const defaultSystemPrompt = await getSystemPrompt(toolUseContext.options.tools, toolUseContext.options.mainLoopModel, additionalWorkingDirectories, toolUseContext.options.mcpClients); 371: forkParentSystemPrompt = buildEffectiveSystemPrompt({ 372: mainThreadAgentDefinition, 373: toolUseContext, 374: customSystemPrompt: toolUseContext.options.customSystemPrompt, 375: defaultSystemPrompt, 376: appendSystemPrompt: toolUseContext.options.appendSystemPrompt 377: }); 378: } 379: promptMessages = buildForkedMessages(prompt, assistantMessage); 380: } else { 381: try { 382: const additionalWorkingDirectories = Array.from(appState.toolPermissionContext.additionalWorkingDirectories.keys()); 383: const agentPrompt = selectedAgent.getSystemPrompt({ 384: toolUseContext 385: }); 386: if (selectedAgent.memory) { 387: logEvent('tengu_agent_memory_loaded', { 388: ...("external" === 'ant' && { 389: agent_type: selectedAgent.agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 390: }), 391: scope: selectedAgent.memory as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 392: source: 'subagent' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 393: }); 394: } 395: enhancedSystemPrompt = await enhanceSystemPromptWithEnvDetails([agentPrompt], resolvedAgentModel, additionalWorkingDirectories); 396: } catch (error) { 397: logForDebugging(`Failed to get system prompt for agent ${selectedAgent.agentType}: ${errorMessage(error)}`); 398: } 399: promptMessages = [createUserMessage({ 400: content: prompt 401: })]; 402: } 403: const metadata = { 404: prompt, 405: resolvedAgentModel, 406: isBuiltInAgent: isBuiltInAgent(selectedAgent), 407: startTime, 408: agentType: selectedAgent.agentType, 409: isAsync: (run_in_background === true || selectedAgent.background === true) && !isBackgroundTasksDisabled 410: }; 411: const isCoordinator = feature('COORDINATOR_MODE') ? isEnvTruthy(process.env.CLAUDE_CODE_COORDINATOR_MODE) : false; 412: const forceAsync = isForkSubagentEnabled(); 413: const assistantForceAsync = feature('KAIROS') ? appState.kairosEnabled : false; 414: const shouldRunAsync = (run_in_background === true || selectedAgent.background === true || isCoordinator || forceAsync || assistantForceAsync || (proactiveModule?.isProactiveActive() ?? false)) && !isBackgroundTasksDisabled; 415: const workerPermissionContext = { 416: ...appState.toolPermissionContext, 417: mode: selectedAgent.permissionMode ?? 'acceptEdits' 418: }; 419: const workerTools = assembleToolPool(workerPermissionContext, appState.mcp.tools); 420: const earlyAgentId = createAgentId(); 421: let worktreeInfo: { 422: worktreePath: string; 423: worktreeBranch?: string; 424: headCommit?: string; 425: gitRoot?: string; 426: hookBased?: boolean; 427: } | null = null; 428: if (effectiveIsolation === 'worktree') { 429: const slug = `agent-${earlyAgentId.slice(0, 8)}`; 430: worktreeInfo = await createAgentWorktree(slug); 431: } 432: if (isForkPath && worktreeInfo) { 433: promptMessages.push(createUserMessage({ 434: content: buildWorktreeNotice(getCwd(), worktreeInfo.worktreePath) 435: })); 436: } 437: const runAgentParams: Parameters<typeof runAgent>[0] = { 438: agentDefinition: selectedAgent, 439: promptMessages, 440: toolUseContext, 441: canUseTool, 442: isAsync: shouldRunAsync, 443: querySource: toolUseContext.options.querySource ?? getQuerySourceForAgent(selectedAgent.agentType, isBuiltInAgent(selectedAgent)), 444: model: isForkPath ? undefined : model, 445: override: isForkPath ? { 446: systemPrompt: forkParentSystemPrompt 447: } : enhancedSystemPrompt && !worktreeInfo && !cwd ? { 448: systemPrompt: asSystemPrompt(enhancedSystemPrompt) 449: } : undefined, 450: availableTools: isForkPath ? toolUseContext.options.tools : workerTools, 451: forkContextMessages: isForkPath ? toolUseContext.messages : undefined, 452: ...(isForkPath && { 453: useExactTools: true 454: }), 455: worktreePath: worktreeInfo?.worktreePath, 456: description 457: }; 458: const cwdOverridePath = cwd ?? worktreeInfo?.worktreePath; 459: const wrapWithCwd = <T,>(fn: () => T): T => cwdOverridePath ? runWithCwdOverride(cwdOverridePath, fn) : fn(); 460: const cleanupWorktreeIfNeeded = async (): Promise<{ 461: worktreePath?: string; 462: worktreeBranch?: string; 463: }> => { 464: if (!worktreeInfo) return {}; 465: const { 466: worktreePath, 467: worktreeBranch, 468: headCommit, 469: gitRoot, 470: hookBased 471: } = worktreeInfo; 472: worktreeInfo = null; 473: if (hookBased) { 474: logForDebugging(`Hook-based agent worktree kept at: ${worktreePath}`); 475: return { 476: worktreePath 477: }; 478: } 479: if (headCommit) { 480: const changed = await hasWorktreeChanges(worktreePath, headCommit); 481: if (!changed) { 482: await removeAgentWorktree(worktreePath, worktreeBranch, gitRoot); 483: void writeAgentMetadata(asAgentId(earlyAgentId), { 484: agentType: selectedAgent.agentType, 485: description 486: }).catch(_err => logForDebugging(`Failed to clear worktree metadata: ${_err}`)); 487: return {}; 488: } 489: } 490: logForDebugging(`Agent worktree has changes, keeping: ${worktreePath}`); 491: return { 492: worktreePath, 493: worktreeBranch 494: }; 495: }; 496: if (shouldRunAsync) { 497: const asyncAgentId = earlyAgentId; 498: const agentBackgroundTask = registerAsyncAgent({ 499: agentId: asyncAgentId, 500: description, 501: prompt, 502: selectedAgent, 503: setAppState: rootSetAppState, 504: toolUseId: toolUseContext.toolUseId 505: }); 506: if (name) { 507: rootSetAppState(prev => { 508: const next = new Map(prev.agentNameRegistry); 509: next.set(name, asAgentId(asyncAgentId)); 510: return { 511: ...prev, 512: agentNameRegistry: next 513: }; 514: }); 515: } 516: const asyncAgentContext = { 517: agentId: asyncAgentId, 518: parentSessionId: getParentSessionId(), 519: agentType: 'subagent' as const, 520: subagentName: selectedAgent.agentType, 521: isBuiltIn: isBuiltInAgent(selectedAgent), 522: invokingRequestId: assistantMessage?.requestId, 523: invocationKind: 'spawn' as const, 524: invocationEmitted: false 525: }; 526: void runWithAgentContext(asyncAgentContext, () => wrapWithCwd(() => runAsyncAgentLifecycle({ 527: taskId: agentBackgroundTask.agentId, 528: abortController: agentBackgroundTask.abortController!, 529: makeStream: onCacheSafeParams => runAgent({ 530: ...runAgentParams, 531: override: { 532: ...runAgentParams.override, 533: agentId: asAgentId(agentBackgroundTask.agentId), 534: abortController: agentBackgroundTask.abortController! 535: }, 536: onCacheSafeParams 537: }), 538: metadata, 539: description, 540: toolUseContext, 541: rootSetAppState, 542: agentIdForCleanup: asyncAgentId, 543: enableSummarization: isCoordinator || isForkSubagentEnabled() || getSdkAgentProgressSummariesEnabled(), 544: getWorktreeResult: cleanupWorktreeIfNeeded 545: }))); 546: const canReadOutputFile = toolUseContext.options.tools.some(t => toolMatchesName(t, FILE_READ_TOOL_NAME) || toolMatchesName(t, BASH_TOOL_NAME)); 547: return { 548: data: { 549: isAsync: true as const, 550: status: 'async_launched' as const, 551: agentId: agentBackgroundTask.agentId, 552: description: description, 553: prompt: prompt, 554: outputFile: getTaskOutputPath(agentBackgroundTask.agentId), 555: canReadOutputFile 556: } 557: }; 558: } else { 559: const syncAgentId = asAgentId(earlyAgentId); 560: const syncAgentContext = { 561: agentId: syncAgentId, 562: parentSessionId: getParentSessionId(), 563: agentType: 'subagent' as const, 564: subagentName: selectedAgent.agentType, 565: isBuiltIn: isBuiltInAgent(selectedAgent), 566: invokingRequestId: assistantMessage?.requestId, 567: invocationKind: 'spawn' as const, 568: invocationEmitted: false 569: }; 570: return runWithAgentContext(syncAgentContext, () => wrapWithCwd(async () => { 571: const agentMessages: MessageType[] = []; 572: const agentStartTime = Date.now(); 573: const syncTracker = createProgressTracker(); 574: const syncResolveActivity = createActivityDescriptionResolver(toolUseContext.options.tools); 575: if (promptMessages.length > 0) { 576: const normalizedPromptMessages = normalizeMessages(promptMessages); 577: const normalizedFirstMessage = normalizedPromptMessages.find((m): m is NormalizedUserMessage => m.type === 'user'); 578: if (normalizedFirstMessage && normalizedFirstMessage.type === 'user' && onProgress) { 579: onProgress({ 580: toolUseID: `agent_${assistantMessage.message.id}`, 581: data: { 582: message: normalizedFirstMessage, 583: type: 'agent_progress', 584: prompt, 585: agentId: syncAgentId 586: } 587: }); 588: } 589: } 590: let foregroundTaskId: string | undefined; 591: let backgroundPromise: Promise<{ 592: type: 'background'; 593: }> | undefined; 594: let cancelAutoBackground: (() => void) | undefined; 595: if (!isBackgroundTasksDisabled) { 596: const registration = registerAgentForeground({ 597: agentId: syncAgentId, 598: description, 599: prompt, 600: selectedAgent, 601: setAppState: rootSetAppState, 602: toolUseId: toolUseContext.toolUseId, 603: autoBackgroundMs: getAutoBackgroundMs() || undefined 604: }); 605: foregroundTaskId = registration.taskId; 606: backgroundPromise = registration.backgroundSignal.then(() => ({ 607: type: 'background' as const 608: })); 609: cancelAutoBackground = registration.cancelAutoBackground; 610: } 611: let backgroundHintShown = false; 612: let wasBackgrounded = false; 613: let stopForegroundSummarization: (() => void) | undefined; 614: const summaryTaskId = foregroundTaskId; 615: const agentIterator = runAgent({ 616: ...runAgentParams, 617: override: { 618: ...runAgentParams.override, 619: agentId: syncAgentId 620: }, 621: onCacheSafeParams: summaryTaskId && getSdkAgentProgressSummariesEnabled() ? (params: CacheSafeParams) => { 622: const { 623: stop 624: } = startAgentSummarization(summaryTaskId, syncAgentId, params, rootSetAppState); 625: stopForegroundSummarization = stop; 626: } : undefined 627: })[Symbol.asyncIterator](); 628: let syncAgentError: Error | undefined; 629: let wasAborted = false; 630: let worktreeResult: { 631: worktreePath?: string; 632: worktreeBranch?: string; 633: } = {}; 634: try { 635: while (true) { 636: const elapsed = Date.now() - agentStartTime; 637: if (!isBackgroundTasksDisabled && !backgroundHintShown && elapsed >= PROGRESS_THRESHOLD_MS && toolUseContext.setToolJSX) { 638: backgroundHintShown = true; 639: toolUseContext.setToolJSX({ 640: jsx: <BackgroundHint />, 641: shouldHidePromptInput: false, 642: shouldContinueAnimation: true, 643: showSpinner: true 644: }); 645: } 646: const nextMessagePromise = agentIterator.next(); 647: const raceResult = backgroundPromise ? await Promise.race([nextMessagePromise.then(r => ({ 648: type: 'message' as const, 649: result: r 650: })), backgroundPromise]) : { 651: type: 'message' as const, 652: result: await nextMessagePromise 653: }; 654: if (raceResult.type === 'background' && foregroundTaskId) { 655: const appState = toolUseContext.getAppState(); 656: const task = appState.tasks[foregroundTaskId]; 657: if (isLocalAgentTask(task) && task.isBackgrounded) { 658: const backgroundedTaskId = foregroundTaskId; 659: wasBackgrounded = true; 660: stopForegroundSummarization?.(); 661: void runWithAgentContext(syncAgentContext, async () => { 662: let stopBackgroundedSummarization: (() => void) | undefined; 663: try { 664: await Promise.race([agentIterator.return(undefined).catch(() => {}), sleep(1000)]); 665: const tracker = createProgressTracker(); 666: const resolveActivity2 = createActivityDescriptionResolver(toolUseContext.options.tools); 667: for (const existingMsg of agentMessages) { 668: updateProgressFromMessage(tracker, existingMsg, resolveActivity2, toolUseContext.options.tools); 669: } 670: for await (const msg of runAgent({ 671: ...runAgentParams, 672: isAsync: true, 673: override: { 674: ...runAgentParams.override, 675: agentId: asAgentId(backgroundedTaskId), 676: abortController: task.abortController 677: }, 678: onCacheSafeParams: getSdkAgentProgressSummariesEnabled() ? (params: CacheSafeParams) => { 679: const { 680: stop 681: } = startAgentSummarization(backgroundedTaskId, asAgentId(backgroundedTaskId), params, rootSetAppState); 682: stopBackgroundedSummarization = stop; 683: } : undefined 684: })) { 685: agentMessages.push(msg); 686: updateProgressFromMessage(tracker, msg, resolveActivity2, toolUseContext.options.tools); 687: updateAsyncAgentProgress(backgroundedTaskId, getProgressUpdate(tracker), rootSetAppState); 688: const lastToolName = getLastToolUseName(msg); 689: if (lastToolName) { 690: emitTaskProgress(tracker, backgroundedTaskId, toolUseContext.toolUseId, description, startTime, lastToolName); 691: } 692: } 693: const agentResult = finalizeAgentTool(agentMessages, backgroundedTaskId, metadata); 694: completeAsyncAgent(agentResult, rootSetAppState); 695: let finalMessage = extractTextContent(agentResult.content, '\n'); 696: if (feature('TRANSCRIPT_CLASSIFIER')) { 697: const backgroundedAppState = toolUseContext.getAppState(); 698: const handoffWarning = await classifyHandoffIfNeeded({ 699: agentMessages, 700: tools: toolUseContext.options.tools, 701: toolPermissionContext: backgroundedAppState.toolPermissionContext, 702: abortSignal: task.abortController!.signal, 703: subagentType: selectedAgent.agentType, 704: totalToolUseCount: agentResult.totalToolUseCount 705: }); 706: if (handoffWarning) { 707: finalMessage = `${handoffWarning}\n\n${finalMessage}`; 708: } 709: } 710: const worktreeResult = await cleanupWorktreeIfNeeded(); 711: enqueueAgentNotification({ 712: taskId: backgroundedTaskId, 713: description, 714: status: 'completed', 715: setAppState: rootSetAppState, 716: finalMessage, 717: usage: { 718: totalTokens: getTokenCountFromTracker(tracker), 719: toolUses: agentResult.totalToolUseCount, 720: durationMs: agentResult.totalDurationMs 721: }, 722: toolUseId: toolUseContext.toolUseId, 723: ...worktreeResult 724: }); 725: } catch (error) { 726: if (error instanceof AbortError) { 727: killAsyncAgent(backgroundedTaskId, rootSetAppState); 728: logEvent('tengu_agent_tool_terminated', { 729: agent_type: metadata.agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 730: model: metadata.resolvedAgentModel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 731: duration_ms: Date.now() - metadata.startTime, 732: is_async: true, 733: is_built_in_agent: metadata.isBuiltInAgent, 734: reason: 'user_cancel_background' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 735: }); 736: const worktreeResult = await cleanupWorktreeIfNeeded(); 737: const partialResult = extractPartialResult(agentMessages); 738: enqueueAgentNotification({ 739: taskId: backgroundedTaskId, 740: description, 741: status: 'killed', 742: setAppState: rootSetAppState, 743: toolUseId: toolUseContext.toolUseId, 744: finalMessage: partialResult, 745: ...worktreeResult 746: }); 747: return; 748: } 749: const errMsg = errorMessage(error); 750: failAsyncAgent(backgroundedTaskId, errMsg, rootSetAppState); 751: const worktreeResult = await cleanupWorktreeIfNeeded(); 752: enqueueAgentNotification({ 753: taskId: backgroundedTaskId, 754: description, 755: status: 'failed', 756: error: errMsg, 757: setAppState: rootSetAppState, 758: toolUseId: toolUseContext.toolUseId, 759: ...worktreeResult 760: }); 761: } finally { 762: stopBackgroundedSummarization?.(); 763: clearInvokedSkillsForAgent(syncAgentId); 764: clearDumpState(syncAgentId); 765: } 766: }); 767: const canReadOutputFile = toolUseContext.options.tools.some(t => toolMatchesName(t, FILE_READ_TOOL_NAME) || toolMatchesName(t, BASH_TOOL_NAME)); 768: return { 769: data: { 770: isAsync: true as const, 771: status: 'async_launched' as const, 772: agentId: backgroundedTaskId, 773: description: description, 774: prompt: prompt, 775: outputFile: getTaskOutputPath(backgroundedTaskId), 776: canReadOutputFile 777: } 778: }; 779: } 780: } 781: if (raceResult.type !== 'message') { 782: continue; 783: } 784: const { 785: result 786: } = raceResult; 787: if (result.done) break; 788: const message = result.value; 789: agentMessages.push(message); 790: updateProgressFromMessage(syncTracker, message, syncResolveActivity, toolUseContext.options.tools); 791: if (foregroundTaskId) { 792: const lastToolName = getLastToolUseName(message); 793: if (lastToolName) { 794: emitTaskProgress(syncTracker, foregroundTaskId, toolUseContext.toolUseId, description, agentStartTime, lastToolName); 795: if (getSdkAgentProgressSummariesEnabled()) { 796: updateAsyncAgentProgress(foregroundTaskId, getProgressUpdate(syncTracker), rootSetAppState); 797: } 798: } 799: } 800: if (message.type === 'progress' && (message.data.type === 'bash_progress' || message.data.type === 'powershell_progress') && onProgress) { 801: onProgress({ 802: toolUseID: message.toolUseID, 803: data: message.data 804: }); 805: } 806: if (message.type !== 'assistant' && message.type !== 'user') { 807: continue; 808: } 809: if (message.type === 'assistant') { 810: const contentLength = getAssistantMessageContentLength(message); 811: if (contentLength > 0) { 812: toolUseContext.setResponseLength(len => len + contentLength); 813: } 814: } 815: const normalizedNew = normalizeMessages([message]); 816: for (const m of normalizedNew) { 817: for (const content of m.message.content) { 818: if (content.type !== 'tool_use' && content.type !== 'tool_result') { 819: continue; 820: } 821: if (onProgress) { 822: onProgress({ 823: toolUseID: `agent_${assistantMessage.message.id}`, 824: data: { 825: message: m, 826: type: 'agent_progress', 827: prompt: '', 828: agentId: syncAgentId 829: } 830: }); 831: } 832: } 833: } 834: } 835: } catch (error) { 836: // Handle errors from the sync agent loop 837: // AbortError should be re-thrown for proper interruption handling 838: if (error instanceof AbortError) { 839: wasAborted = true; 840: logEvent('tengu_agent_tool_terminated', { 841: agent_type: metadata.agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 842: model: metadata.resolvedAgentModel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 843: duration_ms: Date.now() - metadata.startTime, 844: is_async: false, 845: is_built_in_agent: metadata.isBuiltInAgent, 846: reason: 'user_cancel_sync' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 847: }); 848: throw error; 849: } 850: logForDebugging(`Sync agent error: ${errorMessage(error)}`, { 851: level: 'error' 852: }); 853: syncAgentError = toError(error); 854: } finally { 855: if (toolUseContext.setToolJSX) { 856: toolUseContext.setToolJSX(null); 857: } 858: stopForegroundSummarization?.(); 859: if (foregroundTaskId) { 860: unregisterAgentForeground(foregroundTaskId, rootSetAppState); 861: if (!wasBackgrounded) { 862: const progress = getProgressUpdate(syncTracker); 863: enqueueSdkEvent({ 864: type: 'system', 865: subtype: 'task_notification', 866: task_id: foregroundTaskId, 867: tool_use_id: toolUseContext.toolUseId, 868: status: syncAgentError ? 'failed' : wasAborted ? 'stopped' : 'completed', 869: output_file: '', 870: summary: description, 871: usage: { 872: total_tokens: progress.tokenCount, 873: tool_uses: progress.toolUseCount, 874: duration_ms: Date.now() - agentStartTime 875: } 876: }); 877: } 878: } 879: // Clean up scoped skills so they don't accumulate in the global map 880: clearInvokedSkillsForAgent(syncAgentId); 881: if (!wasBackgrounded) { 882: clearDumpState(syncAgentId); 883: } 884: cancelAutoBackground?.(); 885: if (!wasBackgrounded) { 886: worktreeResult = await cleanupWorktreeIfNeeded(); 887: } 888: } 889: const lastMessage = agentMessages.findLast(_ => _.type !== 'system' && _.type !== 'progress'); 890: if (lastMessage && isSyntheticMessage(lastMessage)) { 891: logEvent('tengu_agent_tool_terminated', { 892: agent_type: metadata.agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 893: model: metadata.resolvedAgentModel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 894: duration_ms: Date.now() - metadata.startTime, 895: is_async: false, 896: is_built_in_agent: metadata.isBuiltInAgent, 897: reason: 'user_cancel_sync' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 898: }); 899: throw new AbortError(); 900: } 901: if (syncAgentError) { 902: const hasAssistantMessages = agentMessages.some(msg => msg.type === 'assistant'); 903: if (!hasAssistantMessages) { 904: throw syncAgentError; 905: } 906: logForDebugging(`Sync agent recovering from error with ${agentMessages.length} messages`); 907: } 908: const agentResult = finalizeAgentTool(agentMessages, syncAgentId, metadata); 909: if (feature('TRANSCRIPT_CLASSIFIER')) { 910: const currentAppState = toolUseContext.getAppState(); 911: const handoffWarning = await classifyHandoffIfNeeded({ 912: agentMessages, 913: tools: toolUseContext.options.tools, 914: toolPermissionContext: currentAppState.toolPermissionContext, 915: abortSignal: toolUseContext.abortController.signal, 916: subagentType: selectedAgent.agentType, 917: totalToolUseCount: agentResult.totalToolUseCount 918: }); 919: if (handoffWarning) { 920: agentResult.content = [{ 921: type: 'text' as const, 922: text: handoffWarning 923: }, ...agentResult.content]; 924: } 925: } 926: return { 927: data: { 928: status: 'completed' as const, 929: prompt, 930: ...agentResult, 931: ...worktreeResult 932: } 933: }; 934: })); 935: } 936: }, 937: isReadOnly() { 938: return true; 939: }, 940: toAutoClassifierInput(input) { 941: const i = input as AgentToolInput; 942: const tags = [i.subagent_type, i.mode ? `mode=${i.mode}` : undefined].filter((t): t is string => t !== undefined); 943: const prefix = tags.length > 0 ? `(${tags.join(', ')}): ` : ': '; 944: return `${prefix}${i.prompt}`; 945: }, 946: isConcurrencySafe() { 947: return true; 948: }, 949: userFacingName, 950: userFacingNameBackgroundColor, 951: getActivityDescription(input) { 952: return input?.description ?? 'Running task'; 953: }, 954: async checkPermissions(input, context): Promise<PermissionResult> { 955: const appState = context.getAppState(); 956: if ("external" === 'ant' && appState.toolPermissionContext.mode === 'auto') { 957: return { 958: behavior: 'passthrough', 959: message: 'Agent tool requires permission to spawn sub-agents.' 960: }; 961: } 962: return { 963: behavior: 'allow', 964: updatedInput: input 965: }; 966: }, 967: mapToolResultToToolResultBlockParam(data, toolUseID) { 968: const internalData = data as InternalOutput; 969: if (typeof internalData === 'object' && internalData !== null && 'status' in internalData && internalData.status === 'teammate_spawned') { 970: const spawnData = internalData as TeammateSpawnedOutput; 971: return { 972: tool_use_id: toolUseID, 973: type: 'tool_result', 974: content: [{ 975: type: 'text', 976: text: `Spawned successfully. 977: agent_id: ${spawnData.teammate_id} 978: name: ${spawnData.name} 979: team_name: ${spawnData.team_name} 980: The agent is now running and will receive instructions via mailbox.` 981: }] 982: }; 983: } 984: if ('status' in internalData && internalData.status === 'remote_launched') { 985: const r = internalData; 986: return { 987: tool_use_id: toolUseID, 988: type: 'tool_result', 989: content: [{ 990: type: 'text', 991: text: `Remote agent launched in CCR.\ntaskId: ${r.taskId}\nsession_url: ${r.sessionUrl}\noutput_file: ${r.outputFile}\nThe agent is running remotely. You will be notified automatically when it completes.\nBriefly tell the user what you launched and end your response.` 992: }] 993: }; 994: } 995: if (data.status === 'async_launched') { 996: const prefix = `Async agent launched successfully.\nagentId: ${data.agentId} (internal ID - do not mention to user. Use SendMessage with to: '${data.agentId}' to continue this agent.)\nThe agent is working in the background. You will be notified automatically when it completes.`; 997: const instructions = data.canReadOutputFile ? `Do not duplicate this agent's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response.\noutput_file: ${data.outputFile}\nIf asked, you can check progress before completion by using ${FILE_READ_TOOL_NAME} or ${BASH_TOOL_NAME} tail on the output file.` : `Briefly tell the user what you launched and end your response. Do not generate any other text — agent results will arrive in a subsequent message.`; 998: const text = `${prefix}\n${instructions}`; 999: return { 1000: tool_use_id: toolUseID, 1001: type: 'tool_result', 1002: content: [{ 1003: type: 'text', 1004: text 1005: }] 1006: }; 1007: } 1008: if (data.status === 'completed') { 1009: const worktreeData = data as Record<string, unknown>; 1010: const worktreeInfoText = worktreeData.worktreePath ? `\nworktreePath: ${worktreeData.worktreePath}\nworktreeBranch: ${worktreeData.worktreeBranch}` : ''; 1011: // If the subagent completes with no content, the tool_result is just the 1012: // agentId/usage trailer below — a metadata-only block at the prompt tail. 1013: // Some models read that as "nothing to act on" and end their turn 1014: // immediately. Say so explicitly so the parent has something to react to. 1015: const contentOrMarker = data.content.length > 0 ? data.content : [{ 1016: type: 'text' as const, 1017: text: '(Subagent completed but returned no output.)' 1018: }]; 1019: if (data.agentType && ONE_SHOT_BUILTIN_AGENT_TYPES.has(data.agentType) && !worktreeInfoText) { 1020: return { 1021: tool_use_id: toolUseID, 1022: type: 'tool_result', 1023: content: contentOrMarker 1024: }; 1025: } 1026: return { 1027: tool_use_id: toolUseID, 1028: type: 'tool_result', 1029: content: [...contentOrMarker, { 1030: type: 'text', 1031: text: `agentId: ${data.agentId} (use SendMessage with to: '${data.agentId}' to continue this agent)${worktreeInfoText} 1032: <usage>total_tokens: ${data.totalTokens} 1033: tool_uses: ${data.totalToolUseCount} 1034: duration_ms: ${data.totalDurationMs}</usage>` 1035: }] 1036: }; 1037: } 1038: data satisfies never; 1039: throw new Error(`Unexpected agent tool result status: ${(data as { 1040: status: string; 1041: }).status}`); 1042: }, 1043: renderToolResultMessage, 1044: renderToolUseMessage, 1045: renderToolUseTag, 1046: renderToolUseProgressMessage, 1047: renderToolUseRejectedMessage, 1048: renderToolUseErrorMessage, 1049: renderGroupedToolUse: renderGroupedAgentToolUse 1050: } satisfies ToolDef<InputSchema, Output, Progress>); 1051: function resolveTeamName(input: { 1052: team_name?: string; 1053: }, appState: { 1054: teamContext?: { 1055: teamName: string; 1056: }; 1057: }): string | undefined { 1058: if (!isAgentSwarmsEnabled()) return undefined; 1059: return input.team_name || appState.teamContext?.teamName; 1060: }

File: src/tools/AgentTool/agentToolUtils.ts

typescript 1: import { feature } from 'bun:bundle' 2: import { z } from 'zod/v4' 3: import { clearInvokedSkillsForAgent } from '../../bootstrap/state.js' 4: import { 5: ALL_AGENT_DISALLOWED_TOOLS, 6: ASYNC_AGENT_ALLOWED_TOOLS, 7: CUSTOM_AGENT_DISALLOWED_TOOLS, 8: IN_PROCESS_TEAMMATE_ALLOWED_TOOLS, 9: } from '../../constants/tools.js' 10: import { startAgentSummarization } from '../../services/AgentSummary/agentSummary.js' 11: import { 12: type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 13: logEvent, 14: } from '../../services/analytics/index.js' 15: import { clearDumpState } from '../../services/api/dumpPrompts.js' 16: import type { AppState } from '../../state/AppState.js' 17: import type { 18: Tool, 19: ToolPermissionContext, 20: Tools, 21: ToolUseContext, 22: } from '../../Tool.js' 23: import { toolMatchesName } from '../../Tool.js' 24: import { 25: completeAgentTask as completeAsyncAgent, 26: createActivityDescriptionResolver, 27: createProgressTracker, 28: enqueueAgentNotification, 29: failAgentTask as failAsyncAgent, 30: getProgressUpdate, 31: getTokenCountFromTracker, 32: isLocalAgentTask, 33: killAsyncAgent, 34: type ProgressTracker, 35: updateAgentProgress as updateAsyncAgentProgress, 36: updateProgressFromMessage, 37: } from '../../tasks/LocalAgentTask/LocalAgentTask.js' 38: import { asAgentId } from '../../types/ids.js' 39: import type { Message as MessageType } from '../../types/message.js' 40: import { isAgentSwarmsEnabled } from '../../utils/agentSwarmsEnabled.js' 41: import { logForDebugging } from '../../utils/debug.js' 42: import { isInProtectedNamespace } from '../../utils/envUtils.js' 43: import { AbortError, errorMessage } from '../../utils/errors.js' 44: import type { CacheSafeParams } from '../../utils/forkedAgent.js' 45: import { lazySchema } from '../../utils/lazySchema.js' 46: import { 47: extractTextContent, 48: getLastAssistantMessage, 49: } from '../../utils/messages.js' 50: import type { PermissionMode } from '../../utils/permissions/PermissionMode.js' 51: import { permissionRuleValueFromString } from '../../utils/permissions/permissionRuleParser.js' 52: import { 53: buildTranscriptForClassifier, 54: classifyYoloAction, 55: } from '../../utils/permissions/yoloClassifier.js' 56: import { emitTaskProgress as emitTaskProgressEvent } from '../../utils/task/sdkProgress.js' 57: import { isInProcessTeammate } from '../../utils/teammateContext.js' 58: import { getTokenCountFromUsage } from '../../utils/tokens.js' 59: import { EXIT_PLAN_MODE_V2_TOOL_NAME } from '../ExitPlanModeTool/constants.js' 60: import { AGENT_TOOL_NAME, LEGACY_AGENT_TOOL_NAME } from './constants.js' 61: import type { AgentDefinition } from './loadAgentsDir.js' 62: export type ResolvedAgentTools = { 63: hasWildcard: boolean 64: validTools: string[] 65: invalidTools: string[] 66: resolvedTools: Tools 67: allowedAgentTypes?: string[] 68: } 69: export function filterToolsForAgent({ 70: tools, 71: isBuiltIn, 72: isAsync = false, 73: permissionMode, 74: }: { 75: tools: Tools 76: isBuiltIn: boolean 77: isAsync?: boolean 78: permissionMode?: PermissionMode 79: }): Tools { 80: return tools.filter(tool => { 81: if (tool.name.startsWith('mcp__')) { 82: return true 83: } 84: if ( 85: toolMatchesName(tool, EXIT_PLAN_MODE_V2_TOOL_NAME) && 86: permissionMode === 'plan' 87: ) { 88: return true 89: } 90: if (ALL_AGENT_DISALLOWED_TOOLS.has(tool.name)) { 91: return false 92: } 93: if (!isBuiltIn && CUSTOM_AGENT_DISALLOWED_TOOLS.has(tool.name)) { 94: return false 95: } 96: if (isAsync && !ASYNC_AGENT_ALLOWED_TOOLS.has(tool.name)) { 97: if (isAgentSwarmsEnabled() && isInProcessTeammate()) { 98: if (toolMatchesName(tool, AGENT_TOOL_NAME)) { 99: return true 100: } 101: if (IN_PROCESS_TEAMMATE_ALLOWED_TOOLS.has(tool.name)) { 102: return true 103: } 104: } 105: return false 106: } 107: return true 108: }) 109: } 110: export function resolveAgentTools( 111: agentDefinition: Pick< 112: AgentDefinition, 113: 'tools' | 'disallowedTools' | 'source' | 'permissionMode' 114: >, 115: availableTools: Tools, 116: isAsync = false, 117: isMainThread = false, 118: ): ResolvedAgentTools { 119: const { 120: tools: agentTools, 121: disallowedTools, 122: source, 123: permissionMode, 124: } = agentDefinition 125: const filteredAvailableTools = isMainThread 126: ? availableTools 127: : filterToolsForAgent({ 128: tools: availableTools, 129: isBuiltIn: source === 'built-in', 130: isAsync, 131: permissionMode, 132: }) 133: const disallowedToolSet = new Set( 134: disallowedTools?.map(toolSpec => { 135: const { toolName } = permissionRuleValueFromString(toolSpec) 136: return toolName 137: }) ?? [], 138: ) 139: const allowedAvailableTools = filteredAvailableTools.filter( 140: tool => !disallowedToolSet.has(tool.name), 141: ) 142: const hasWildcard = 143: agentTools === undefined || 144: (agentTools.length === 1 && agentTools[0] === '*') 145: if (hasWildcard) { 146: return { 147: hasWildcard: true, 148: validTools: [], 149: invalidTools: [], 150: resolvedTools: allowedAvailableTools, 151: } 152: } 153: const availableToolMap = new Map<string, Tool>() 154: for (const tool of allowedAvailableTools) { 155: availableToolMap.set(tool.name, tool) 156: } 157: const validTools: string[] = [] 158: const invalidTools: string[] = [] 159: const resolved: Tool[] = [] 160: const resolvedToolsSet = new Set<Tool>() 161: let allowedAgentTypes: string[] | undefined 162: for (const toolSpec of agentTools) { 163: const { toolName, ruleContent } = permissionRuleValueFromString(toolSpec) 164: if (toolName === AGENT_TOOL_NAME) { 165: if (ruleContent) { 166: allowedAgentTypes = ruleContent.split(',').map(s => s.trim()) 167: } 168: if (!isMainThread) { 169: validTools.push(toolSpec) 170: continue 171: } 172: } 173: const tool = availableToolMap.get(toolName) 174: if (tool) { 175: validTools.push(toolSpec) 176: if (!resolvedToolsSet.has(tool)) { 177: resolved.push(tool) 178: resolvedToolsSet.add(tool) 179: } 180: } else { 181: invalidTools.push(toolSpec) 182: } 183: } 184: return { 185: hasWildcard: false, 186: validTools, 187: invalidTools, 188: resolvedTools: resolved, 189: allowedAgentTypes, 190: } 191: } 192: export const agentToolResultSchema = lazySchema(() => 193: z.object({ 194: agentId: z.string(), 195: agentType: z.string().optional(), 196: content: z.array(z.object({ type: z.literal('text'), text: z.string() })), 197: totalToolUseCount: z.number(), 198: totalDurationMs: z.number(), 199: totalTokens: z.number(), 200: usage: z.object({ 201: input_tokens: z.number(), 202: output_tokens: z.number(), 203: cache_creation_input_tokens: z.number().nullable(), 204: cache_read_input_tokens: z.number().nullable(), 205: server_tool_use: z 206: .object({ 207: web_search_requests: z.number(), 208: web_fetch_requests: z.number(), 209: }) 210: .nullable(), 211: service_tier: z.enum(['standard', 'priority', 'batch']).nullable(), 212: cache_creation: z 213: .object({ 214: ephemeral_1h_input_tokens: z.number(), 215: ephemeral_5m_input_tokens: z.number(), 216: }) 217: .nullable(), 218: }), 219: }), 220: ) 221: export type AgentToolResult = z.input<ReturnType<typeof agentToolResultSchema>> 222: export function countToolUses(messages: MessageType[]): number { 223: let count = 0 224: for (const m of messages) { 225: if (m.type === 'assistant') { 226: for (const block of m.message.content) { 227: if (block.type === 'tool_use') { 228: count++ 229: } 230: } 231: } 232: } 233: return count 234: } 235: export function finalizeAgentTool( 236: agentMessages: MessageType[], 237: agentId: string, 238: metadata: { 239: prompt: string 240: resolvedAgentModel: string 241: isBuiltInAgent: boolean 242: startTime: number 243: agentType: string 244: isAsync: boolean 245: }, 246: ): AgentToolResult { 247: const { 248: prompt, 249: resolvedAgentModel, 250: isBuiltInAgent, 251: startTime, 252: agentType, 253: isAsync, 254: } = metadata 255: const lastAssistantMessage = getLastAssistantMessage(agentMessages) 256: if (lastAssistantMessage === undefined) { 257: throw new Error('No assistant messages found') 258: } 259: let content = lastAssistantMessage.message.content.filter( 260: _ => _.type === 'text', 261: ) 262: if (content.length === 0) { 263: for (let i = agentMessages.length - 1; i >= 0; i--) { 264: const m = agentMessages[i]! 265: if (m.type !== 'assistant') continue 266: const textBlocks = m.message.content.filter(_ => _.type === 'text') 267: if (textBlocks.length > 0) { 268: content = textBlocks 269: break 270: } 271: } 272: } 273: const totalTokens = getTokenCountFromUsage(lastAssistantMessage.message.usage) 274: const totalToolUseCount = countToolUses(agentMessages) 275: logEvent('tengu_agent_tool_completed', { 276: agent_type: 277: agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 278: model: 279: resolvedAgentModel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 280: prompt_char_count: prompt.length, 281: response_char_count: content.length, 282: assistant_message_count: agentMessages.length, 283: total_tool_uses: totalToolUseCount, 284: duration_ms: Date.now() - startTime, 285: total_tokens: totalTokens, 286: is_built_in_agent: isBuiltInAgent, 287: is_async: isAsync, 288: }) 289: const lastRequestId = lastAssistantMessage.requestId 290: if (lastRequestId) { 291: logEvent('tengu_cache_eviction_hint', { 292: scope: 293: 'subagent_end' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 294: last_request_id: 295: lastRequestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 296: }) 297: } 298: return { 299: agentId, 300: agentType, 301: content, 302: totalDurationMs: Date.now() - startTime, 303: totalTokens, 304: totalToolUseCount, 305: usage: lastAssistantMessage.message.usage, 306: } 307: } 308: export function getLastToolUseName(message: MessageType): string | undefined { 309: if (message.type !== 'assistant') return undefined 310: const block = message.message.content.findLast(b => b.type === 'tool_use') 311: return block?.type === 'tool_use' ? block.name : undefined 312: } 313: export function emitTaskProgress( 314: tracker: ProgressTracker, 315: taskId: string, 316: toolUseId: string | undefined, 317: description: string, 318: startTime: number, 319: lastToolName: string, 320: ): void { 321: const progress = getProgressUpdate(tracker) 322: emitTaskProgressEvent({ 323: taskId, 324: toolUseId, 325: description: progress.lastActivity?.activityDescription ?? description, 326: startTime, 327: totalTokens: progress.tokenCount, 328: toolUses: progress.toolUseCount, 329: lastToolName, 330: }) 331: } 332: export async function classifyHandoffIfNeeded({ 333: agentMessages, 334: tools, 335: toolPermissionContext, 336: abortSignal, 337: subagentType, 338: totalToolUseCount, 339: }: { 340: agentMessages: MessageType[] 341: tools: Tools 342: toolPermissionContext: AppState['toolPermissionContext'] 343: abortSignal: AbortSignal 344: subagentType: string 345: totalToolUseCount: number 346: }): Promise<string | null> { 347: if (feature('TRANSCRIPT_CLASSIFIER')) { 348: if (toolPermissionContext.mode !== 'auto') return null 349: const agentTranscript = buildTranscriptForClassifier(agentMessages, tools) 350: if (!agentTranscript) return null 351: const classifierResult = await classifyYoloAction( 352: agentMessages, 353: { 354: role: 'user', 355: content: [ 356: { 357: type: 'text', 358: text: "Sub-agent has finished and is handing back control to the main agent. Review the sub-agent's work based on the block rules and let the main agent know if any file is dangerous (the main agent will see the reason).", 359: }, 360: ], 361: }, 362: tools, 363: toolPermissionContext as ToolPermissionContext, 364: abortSignal, 365: ) 366: const handoffDecision = classifierResult.unavailable 367: ? 'unavailable' 368: : classifierResult.shouldBlock 369: ? 'blocked' 370: : 'allowed' 371: logEvent('tengu_auto_mode_decision', { 372: decision: 373: handoffDecision as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 374: toolName: 375: LEGACY_AGENT_TOOL_NAME as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 376: inProtectedNamespace: isInProtectedNamespace(), 377: classifierModel: 378: classifierResult.model as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 379: agentType: 380: subagentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 381: toolUseCount: totalToolUseCount, 382: isHandoff: true, 383: agentMsgId: getLastAssistantMessage(agentMessages)?.message 384: .id as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 385: classifierStage: 386: classifierResult.stage as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 387: classifierStage1RequestId: 388: classifierResult.stage1RequestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 389: classifierStage1MsgId: 390: classifierResult.stage1MsgId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 391: classifierStage2RequestId: 392: classifierResult.stage2RequestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 393: classifierStage2MsgId: 394: classifierResult.stage2MsgId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 395: }) 396: if (classifierResult.shouldBlock) { 397: if (classifierResult.unavailable) { 398: logForDebugging( 399: 'Handoff classifier unavailable, allowing sub-agent output with warning', 400: { level: 'warn' }, 401: ) 402: return `Note: The safety classifier was unavailable when reviewing this sub-agent's work. Please carefully verify the sub-agent's actions and output before acting on them.` 403: } 404: logForDebugging( 405: `Handoff classifier flagged sub-agent output: ${classifierResult.reason}`, 406: { level: 'warn' }, 407: ) 408: return `SECURITY WARNING: This sub-agent performed actions that may violate security policy. Reason: ${classifierResult.reason}. Review the sub-agent's actions carefully before acting on its output.` 409: } 410: } 411: return null 412: } 413: export function extractPartialResult( 414: messages: MessageType[], 415: ): string | undefined { 416: for (let i = messages.length - 1; i >= 0; i--) { 417: const m = messages[i]! 418: if (m.type !== 'assistant') continue 419: const text = extractTextContent(m.message.content, '\n') 420: if (text) { 421: return text 422: } 423: } 424: return undefined 425: } 426: type SetAppState = (f: (prev: AppState) => AppState) => void 427: export async function runAsyncAgentLifecycle({ 428: taskId, 429: abortController, 430: makeStream, 431: metadata, 432: description, 433: toolUseContext, 434: rootSetAppState, 435: agentIdForCleanup, 436: enableSummarization, 437: getWorktreeResult, 438: }: { 439: taskId: string 440: abortController: AbortController 441: makeStream: ( 442: onCacheSafeParams: ((p: CacheSafeParams) => void) | undefined, 443: ) => AsyncGenerator<MessageType, void> 444: metadata: Parameters<typeof finalizeAgentTool>[2] 445: description: string 446: toolUseContext: ToolUseContext 447: rootSetAppState: SetAppState 448: agentIdForCleanup: string 449: enableSummarization: boolean 450: getWorktreeResult: () => Promise<{ 451: worktreePath?: string 452: worktreeBranch?: string 453: }> 454: }): Promise<void> { 455: let stopSummarization: (() => void) | undefined 456: const agentMessages: MessageType[] = [] 457: try { 458: const tracker = createProgressTracker() 459: const resolveActivity = createActivityDescriptionResolver( 460: toolUseContext.options.tools, 461: ) 462: const onCacheSafeParams = enableSummarization 463: ? (params: CacheSafeParams) => { 464: const { stop } = startAgentSummarization( 465: taskId, 466: asAgentId(taskId), 467: params, 468: rootSetAppState, 469: ) 470: stopSummarization = stop 471: } 472: : undefined 473: for await (const message of makeStream(onCacheSafeParams)) { 474: agentMessages.push(message) 475: rootSetAppState(prev => { 476: const t = prev.tasks[taskId] 477: if (!isLocalAgentTask(t) || !t.retain) return prev 478: const base = t.messages ?? [] 479: return { 480: ...prev, 481: tasks: { 482: ...prev.tasks, 483: [taskId]: { ...t, messages: [...base, message] }, 484: }, 485: } 486: }) 487: updateProgressFromMessage( 488: tracker, 489: message, 490: resolveActivity, 491: toolUseContext.options.tools, 492: ) 493: updateAsyncAgentProgress( 494: taskId, 495: getProgressUpdate(tracker), 496: rootSetAppState, 497: ) 498: const lastToolName = getLastToolUseName(message) 499: if (lastToolName) { 500: emitTaskProgress( 501: tracker, 502: taskId, 503: toolUseContext.toolUseId, 504: description, 505: metadata.startTime, 506: lastToolName, 507: ) 508: } 509: } 510: stopSummarization?.() 511: const agentResult = finalizeAgentTool(agentMessages, taskId, metadata) 512: completeAsyncAgent(agentResult, rootSetAppState) 513: let finalMessage = extractTextContent(agentResult.content, '\n') 514: if (feature('TRANSCRIPT_CLASSIFIER')) { 515: const handoffWarning = await classifyHandoffIfNeeded({ 516: agentMessages, 517: tools: toolUseContext.options.tools, 518: toolPermissionContext: 519: toolUseContext.getAppState().toolPermissionContext, 520: abortSignal: abortController.signal, 521: subagentType: metadata.agentType, 522: totalToolUseCount: agentResult.totalToolUseCount, 523: }) 524: if (handoffWarning) { 525: finalMessage = `${handoffWarning}\n\n${finalMessage}` 526: } 527: } 528: const worktreeResult = await getWorktreeResult() 529: enqueueAgentNotification({ 530: taskId, 531: description, 532: status: 'completed', 533: setAppState: rootSetAppState, 534: finalMessage, 535: usage: { 536: totalTokens: getTokenCountFromTracker(tracker), 537: toolUses: agentResult.totalToolUseCount, 538: durationMs: agentResult.totalDurationMs, 539: }, 540: toolUseId: toolUseContext.toolUseId, 541: ...worktreeResult, 542: }) 543: } catch (error) { 544: stopSummarization?.() 545: if (error instanceof AbortError) { 546: killAsyncAgent(taskId, rootSetAppState) 547: logEvent('tengu_agent_tool_terminated', { 548: agent_type: 549: metadata.agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 550: model: 551: metadata.resolvedAgentModel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 552: duration_ms: Date.now() - metadata.startTime, 553: is_async: true, 554: is_built_in_agent: metadata.isBuiltInAgent, 555: reason: 556: 'user_kill_async' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 557: }) 558: const worktreeResult = await getWorktreeResult() 559: const partialResult = extractPartialResult(agentMessages) 560: enqueueAgentNotification({ 561: taskId, 562: description, 563: status: 'killed', 564: setAppState: rootSetAppState, 565: toolUseId: toolUseContext.toolUseId, 566: finalMessage: partialResult, 567: ...worktreeResult, 568: }) 569: return 570: } 571: const msg = errorMessage(error) 572: failAsyncAgent(taskId, msg, rootSetAppState) 573: const worktreeResult = await getWorktreeResult() 574: enqueueAgentNotification({ 575: taskId, 576: description, 577: status: 'failed', 578: error: msg, 579: setAppState: rootSetAppState, 580: toolUseId: toolUseContext.toolUseId, 581: ...worktreeResult, 582: }) 583: } finally { 584: clearInvokedSkillsForAgent(agentIdForCleanup) 585: clearDumpState(agentIdForCleanup) 586: } 587: }

File: src/tools/AgentTool/builtInAgents.ts

typescript 1: import { feature } from 'bun:bundle' 2: import { getIsNonInteractiveSession } from '../../bootstrap/state.js' 3: import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js' 4: import { isEnvTruthy } from '../../utils/envUtils.js' 5: import { CLAUDE_CODE_GUIDE_AGENT } from './built-in/claudeCodeGuideAgent.js' 6: import { EXPLORE_AGENT } from './built-in/exploreAgent.js' 7: import { GENERAL_PURPOSE_AGENT } from './built-in/generalPurposeAgent.js' 8: import { PLAN_AGENT } from './built-in/planAgent.js' 9: import { STATUSLINE_SETUP_AGENT } from './built-in/statuslineSetup.js' 10: import { VERIFICATION_AGENT } from './built-in/verificationAgent.js' 11: import type { AgentDefinition } from './loadAgentsDir.js' 12: export function areExplorePlanAgentsEnabled(): boolean { 13: if (feature('BUILTIN_EXPLORE_PLAN_AGENTS')) { 14: return getFeatureValue_CACHED_MAY_BE_STALE('tengu_amber_stoat', true) 15: } 16: return false 17: } 18: export function getBuiltInAgents(): AgentDefinition[] { 19: if ( 20: isEnvTruthy(process.env.CLAUDE_AGENT_SDK_DISABLE_BUILTIN_AGENTS) && 21: getIsNonInteractiveSession() 22: ) { 23: return [] 24: } 25: if (feature('COORDINATOR_MODE')) { 26: if (isEnvTruthy(process.env.CLAUDE_CODE_COORDINATOR_MODE)) { 27: const { getCoordinatorAgents } = 28: require('../../coordinator/workerAgent.js') as typeof import('../../coordinator/workerAgent.js') 29: return getCoordinatorAgents() 30: } 31: } 32: const agents: AgentDefinition[] = [ 33: GENERAL_PURPOSE_AGENT, 34: STATUSLINE_SETUP_AGENT, 35: ] 36: if (areExplorePlanAgentsEnabled()) { 37: agents.push(EXPLORE_AGENT, PLAN_AGENT) 38: } 39: const isNonSdkEntrypoint = 40: process.env.CLAUDE_CODE_ENTRYPOINT !== 'sdk-ts' && 41: process.env.CLAUDE_CODE_ENTRYPOINT !== 'sdk-py' && 42: process.env.CLAUDE_CODE_ENTRYPOINT !== 'sdk-cli' 43: if (isNonSdkEntrypoint) { 44: agents.push(CLAUDE_CODE_GUIDE_AGENT) 45: } 46: if ( 47: feature('VERIFICATION_AGENT') && 48: getFeatureValue_CACHED_MAY_BE_STALE('tengu_hive_evidence', false) 49: ) { 50: agents.push(VERIFICATION_AGENT) 51: } 52: return agents 53: }

File: src/tools/AgentTool/constants.ts

typescript 1: export const AGENT_TOOL_NAME = 'Agent' 2: export const LEGACY_AGENT_TOOL_NAME = 'Task' 3: export const VERIFICATION_AGENT_TYPE = 'verification' 4: export const ONE_SHOT_BUILTIN_AGENT_TYPES: ReadonlySet<string> = new Set([ 5: 'Explore', 6: 'Plan', 7: ])

File: src/tools/AgentTool/forkSubagent.ts

typescript 1: import { feature } from 'bun:bundle' 2: import type { BetaToolUseBlock } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs' 3: import { randomUUID } from 'crypto' 4: import { getIsNonInteractiveSession } from '../../bootstrap/state.js' 5: import { 6: FORK_BOILERPLATE_TAG, 7: FORK_DIRECTIVE_PREFIX, 8: } from '../../constants/xml.js' 9: import { isCoordinatorMode } from '../../coordinator/coordinatorMode.js' 10: import type { 11: AssistantMessage, 12: Message as MessageType, 13: } from '../../types/message.js' 14: import { logForDebugging } from '../../utils/debug.js' 15: import { createUserMessage } from '../../utils/messages.js' 16: import type { BuiltInAgentDefinition } from './loadAgentsDir.js' 17: export function isForkSubagentEnabled(): boolean { 18: if (feature('FORK_SUBAGENT')) { 19: if (isCoordinatorMode()) return false 20: if (getIsNonInteractiveSession()) return false 21: return true 22: } 23: return false 24: } 25: export const FORK_SUBAGENT_TYPE = 'fork' 26: export const FORK_AGENT = { 27: agentType: FORK_SUBAGENT_TYPE, 28: whenToUse: 29: 'Implicit fork — inherits full conversation context. Not selectable via subagent_type; triggered by omitting subagent_type when the fork experiment is active.', 30: tools: ['*'], 31: maxTurns: 200, 32: model: 'inherit', 33: permissionMode: 'bubble', 34: source: 'built-in', 35: baseDir: 'built-in', 36: getSystemPrompt: () => '', 37: } satisfies BuiltInAgentDefinition 38: /** 39: * Guard against recursive forking. Fork children keep the Agent tool in their 40: * tool pool for cache-identical tool definitions, so we reject fork attempts 41: * at call time by detecting the fork boilerplate tag in conversation history. 42: */ 43: export function isInForkChild(messages: MessageType[]): boolean { 44: return messages.some(m => { 45: if (m.type !== 'user') return false 46: const content = m.message.content 47: if (!Array.isArray(content)) return false 48: return content.some( 49: block => 50: block.type === 'text' && 51: block.text.includes(`<${FORK_BOILERPLATE_TAG}>`), 52: ) 53: }) 54: } 55: const FORK_PLACEHOLDER_RESULT = 'Fork started — processing in background' 56: export function buildForkedMessages( 57: directive: string, 58: assistantMessage: AssistantMessage, 59: ): MessageType[] { 60: const fullAssistantMessage: AssistantMessage = { 61: ...assistantMessage, 62: uuid: randomUUID(), 63: message: { 64: ...assistantMessage.message, 65: content: [...assistantMessage.message.content], 66: }, 67: } 68: const toolUseBlocks = assistantMessage.message.content.filter( 69: (block): block is BetaToolUseBlock => block.type === 'tool_use', 70: ) 71: if (toolUseBlocks.length === 0) { 72: logForDebugging( 73: `No tool_use blocks found in assistant message for fork directive: ${directive.slice(0, 50)}...`, 74: { level: 'error' }, 75: ) 76: return [ 77: createUserMessage({ 78: content: [ 79: { type: 'text' as const, text: buildChildMessage(directive) }, 80: ], 81: }), 82: ] 83: } 84: const toolResultBlocks = toolUseBlocks.map(block => ({ 85: type: 'tool_result' as const, 86: tool_use_id: block.id, 87: content: [ 88: { 89: type: 'text' as const, 90: text: FORK_PLACEHOLDER_RESULT, 91: }, 92: ], 93: })) 94: const toolResultMessage = createUserMessage({ 95: content: [ 96: ...toolResultBlocks, 97: { 98: type: 'text' as const, 99: text: buildChildMessage(directive), 100: }, 101: ], 102: }) 103: return [fullAssistantMessage, toolResultMessage] 104: } 105: export function buildChildMessage(directive: string): string { 106: return `<${FORK_BOILERPLATE_TAG}> 107: STOP. READ THIS FIRST. 108: You are a forked worker process. You are NOT the main agent. 109: RULES (non-negotiable): 110: 1. Your system prompt says "default to forking." IGNORE IT \u2014 that's for the parent. You ARE the fork. Do NOT spawn sub-agents; execute directly. 111: 2. Do NOT converse, ask questions, or suggest next steps 112: 3. Do NOT editorialize or add meta-commentary 113: 4. USE your tools directly: Bash, Read, Write, etc. 114: 5. If you modify files, commit your changes before reporting. Include the commit hash in your report. 115: 6. Do NOT emit text between tool calls. Use tools silently, then report once at the end. 116: 7. Stay strictly within your directive's scope. If you discover related systems outside your scope, mention them in one sentence at most — other workers cover those areas. 117: 8. Keep your report under 500 words unless the directive specifies otherwise. Be factual and concise. 118: 9. Your response MUST begin with "Scope:". No preamble, no thinking-out-loud. 119: 10. REPORT structured facts, then stop 120: Output format (plain text labels, not markdown headers): 121: Scope: <echo back your assigned scope in one sentence> 122: Result: <the answer or key findings, limited to the scope above> 123: Key files: <relevant file paths — include for research tasks> 124: Files changed: <list with commit hash — include only if you modified files> 125: Issues: <list — include only if there are issues to flag> 126: </${FORK_BOILERPLATE_TAG}> 127: ${FORK_DIRECTIVE_PREFIX}${directive}` 128: } 129: export function buildWorktreeNotice( 130: parentCwd: string, 131: worktreeCwd: string, 132: ): string { 133: return `You've inherited the conversation context above from a parent agent working in ${parentCwd}. You are operating in an isolated git worktree at ${worktreeCwd} — same repository, same relative file structure, separate working copy. Paths in the inherited context refer to the parent's working directory; translate them to your worktree root. Re-read files before editing if the parent may have modified them since they appear in the context. Your changes stay in this worktree and will not affect the parent's files.` 134: }

File: src/tools/AgentTool/loadAgentsDir.ts

typescript 1: import { feature } from 'bun:bundle' 2: import memoize from 'lodash-es/memoize.js' 3: import { basename } from 'path' 4: import type { SettingSource } from 'src/utils/settings/constants.js' 5: import { z } from 'zod/v4' 6: import { isAutoMemoryEnabled } from '../../memdir/paths.js' 7: import { 8: type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 9: logEvent, 10: } from '../../services/analytics/index.js' 11: import { 12: type McpServerConfig, 13: McpServerConfigSchema, 14: } from '../../services/mcp/types.js' 15: import type { ToolUseContext } from '../../Tool.js' 16: import { logForDebugging } from '../../utils/debug.js' 17: import { 18: EFFORT_LEVELS, 19: type EffortValue, 20: parseEffortValue, 21: } from '../../utils/effort.js' 22: import { isEnvTruthy } from '../../utils/envUtils.js' 23: import { parsePositiveIntFromFrontmatter } from '../../utils/frontmatterParser.js' 24: import { lazySchema } from '../../utils/lazySchema.js' 25: import { logError } from '../../utils/log.js' 26: import { 27: loadMarkdownFilesForSubdir, 28: parseAgentToolsFromFrontmatter, 29: parseSlashCommandToolsFromFrontmatter, 30: } from '../../utils/markdownConfigLoader.js' 31: import { 32: PERMISSION_MODES, 33: type PermissionMode, 34: } from '../../utils/permissions/PermissionMode.js' 35: import { 36: clearPluginAgentCache, 37: loadPluginAgents, 38: } from '../../utils/plugins/loadPluginAgents.js' 39: import { HooksSchema, type HooksSettings } from '../../utils/settings/types.js' 40: import { jsonStringify } from '../../utils/slowOperations.js' 41: import { FILE_EDIT_TOOL_NAME } from '../FileEditTool/constants.js' 42: import { FILE_READ_TOOL_NAME } from '../FileReadTool/prompt.js' 43: import { FILE_WRITE_TOOL_NAME } from '../FileWriteTool/prompt.js' 44: import { 45: AGENT_COLORS, 46: type AgentColorName, 47: setAgentColor, 48: } from './agentColorManager.js' 49: import { type AgentMemoryScope, loadAgentMemoryPrompt } from './agentMemory.js' 50: import { 51: checkAgentMemorySnapshot, 52: initializeFromSnapshot, 53: } from './agentMemorySnapshot.js' 54: import { getBuiltInAgents } from './builtInAgents.js' 55: export type AgentMcpServerSpec = 56: | string 57: | { [name: string]: McpServerConfig } 58: const AgentMcpServerSpecSchema = lazySchema(() => 59: z.union([ 60: z.string(), 61: z.record(z.string(), McpServerConfigSchema()), 62: ]), 63: ) 64: const AgentJsonSchema = lazySchema(() => 65: z.object({ 66: description: z.string().min(1, 'Description cannot be empty'), 67: tools: z.array(z.string()).optional(), 68: disallowedTools: z.array(z.string()).optional(), 69: prompt: z.string().min(1, 'Prompt cannot be empty'), 70: model: z 71: .string() 72: .trim() 73: .min(1, 'Model cannot be empty') 74: .transform(m => (m.toLowerCase() === 'inherit' ? 'inherit' : m)) 75: .optional(), 76: effort: z.union([z.enum(EFFORT_LEVELS), z.number().int()]).optional(), 77: permissionMode: z.enum(PERMISSION_MODES).optional(), 78: mcpServers: z.array(AgentMcpServerSpecSchema()).optional(), 79: hooks: HooksSchema().optional(), 80: maxTurns: z.number().int().positive().optional(), 81: skills: z.array(z.string()).optional(), 82: initialPrompt: z.string().optional(), 83: memory: z.enum(['user', 'project', 'local']).optional(), 84: background: z.boolean().optional(), 85: isolation: (process.env.USER_TYPE === 'ant' 86: ? z.enum(['worktree', 'remote']) 87: : z.enum(['worktree']) 88: ).optional(), 89: }), 90: ) 91: const AgentsJsonSchema = lazySchema(() => 92: z.record(z.string(), AgentJsonSchema()), 93: ) 94: export type BaseAgentDefinition = { 95: agentType: string 96: whenToUse: string 97: tools?: string[] 98: disallowedTools?: string[] 99: skills?: string[] 100: mcpServers?: AgentMcpServerSpec[] 101: hooks?: HooksSettings 102: color?: AgentColorName 103: model?: string 104: effort?: EffortValue 105: permissionMode?: PermissionMode 106: maxTurns?: number 107: filename?: string 108: baseDir?: string 109: criticalSystemReminder_EXPERIMENTAL?: string 110: requiredMcpServers?: string[] 111: background?: boolean 112: initialPrompt?: string 113: memory?: AgentMemoryScope 114: isolation?: 'worktree' | 'remote' 115: pendingSnapshotUpdate?: { snapshotTimestamp: string } 116: omitClaudeMd?: boolean 117: } 118: export type BuiltInAgentDefinition = BaseAgentDefinition & { 119: source: 'built-in' 120: baseDir: 'built-in' 121: callback?: () => void 122: getSystemPrompt: (params: { 123: toolUseContext: Pick<ToolUseContext, 'options'> 124: }) => string 125: } 126: export type CustomAgentDefinition = BaseAgentDefinition & { 127: getSystemPrompt: () => string 128: source: SettingSource 129: filename?: string 130: baseDir?: string 131: } 132: export type PluginAgentDefinition = BaseAgentDefinition & { 133: getSystemPrompt: () => string 134: source: 'plugin' 135: filename?: string 136: plugin: string 137: } 138: export type AgentDefinition = 139: | BuiltInAgentDefinition 140: | CustomAgentDefinition 141: | PluginAgentDefinition 142: export function isBuiltInAgent( 143: agent: AgentDefinition, 144: ): agent is BuiltInAgentDefinition { 145: return agent.source === 'built-in' 146: } 147: export function isCustomAgent( 148: agent: AgentDefinition, 149: ): agent is CustomAgentDefinition { 150: return agent.source !== 'built-in' && agent.source !== 'plugin' 151: } 152: export function isPluginAgent( 153: agent: AgentDefinition, 154: ): agent is PluginAgentDefinition { 155: return agent.source === 'plugin' 156: } 157: export type AgentDefinitionsResult = { 158: activeAgents: AgentDefinition[] 159: allAgents: AgentDefinition[] 160: failedFiles?: Array<{ path: string; error: string }> 161: allowedAgentTypes?: string[] 162: } 163: export function getActiveAgentsFromList( 164: allAgents: AgentDefinition[], 165: ): AgentDefinition[] { 166: const builtInAgents = allAgents.filter(a => a.source === 'built-in') 167: const pluginAgents = allAgents.filter(a => a.source === 'plugin') 168: const userAgents = allAgents.filter(a => a.source === 'userSettings') 169: const projectAgents = allAgents.filter(a => a.source === 'projectSettings') 170: const managedAgents = allAgents.filter(a => a.source === 'policySettings') 171: const flagAgents = allAgents.filter(a => a.source === 'flagSettings') 172: const agentGroups = [ 173: builtInAgents, 174: pluginAgents, 175: userAgents, 176: projectAgents, 177: flagAgents, 178: managedAgents, 179: ] 180: const agentMap = new Map<string, AgentDefinition>() 181: for (const agents of agentGroups) { 182: for (const agent of agents) { 183: agentMap.set(agent.agentType, agent) 184: } 185: } 186: return Array.from(agentMap.values()) 187: } 188: export function hasRequiredMcpServers( 189: agent: AgentDefinition, 190: availableServers: string[], 191: ): boolean { 192: if (!agent.requiredMcpServers || agent.requiredMcpServers.length === 0) { 193: return true 194: } 195: return agent.requiredMcpServers.every(pattern => 196: availableServers.some(server => 197: server.toLowerCase().includes(pattern.toLowerCase()), 198: ), 199: ) 200: } 201: export function filterAgentsByMcpRequirements( 202: agents: AgentDefinition[], 203: availableServers: string[], 204: ): AgentDefinition[] { 205: return agents.filter(agent => hasRequiredMcpServers(agent, availableServers)) 206: } 207: async function initializeAgentMemorySnapshots( 208: agents: CustomAgentDefinition[], 209: ): Promise<void> { 210: await Promise.all( 211: agents.map(async agent => { 212: if (agent.memory !== 'user') return 213: const result = await checkAgentMemorySnapshot( 214: agent.agentType, 215: agent.memory, 216: ) 217: switch (result.action) { 218: case 'initialize': 219: logForDebugging( 220: `Initializing ${agent.agentType} memory from project snapshot`, 221: ) 222: await initializeFromSnapshot( 223: agent.agentType, 224: agent.memory, 225: result.snapshotTimestamp!, 226: ) 227: break 228: case 'prompt-update': 229: agent.pendingSnapshotUpdate = { 230: snapshotTimestamp: result.snapshotTimestamp!, 231: } 232: logForDebugging( 233: `Newer snapshot available for ${agent.agentType} memory (snapshot: ${result.snapshotTimestamp})`, 234: ) 235: break 236: } 237: }), 238: ) 239: } 240: export const getAgentDefinitionsWithOverrides = memoize( 241: async (cwd: string): Promise<AgentDefinitionsResult> => { 242: if (isEnvTruthy(process.env.CLAUDE_CODE_SIMPLE)) { 243: const builtInAgents = getBuiltInAgents() 244: return { 245: activeAgents: builtInAgents, 246: allAgents: builtInAgents, 247: } 248: } 249: try { 250: const markdownFiles = await loadMarkdownFilesForSubdir('agents', cwd) 251: const failedFiles: Array<{ path: string; error: string }> = [] 252: const customAgents = markdownFiles 253: .map(({ filePath, baseDir, frontmatter, content, source }) => { 254: const agent = parseAgentFromMarkdown( 255: filePath, 256: baseDir, 257: frontmatter, 258: content, 259: source, 260: ) 261: if (!agent) { 262: if (!frontmatter['name']) { 263: return null 264: } 265: const errorMsg = getParseError(frontmatter) 266: failedFiles.push({ path: filePath, error: errorMsg }) 267: logForDebugging( 268: `Failed to parse agent from ${filePath}: ${errorMsg}`, 269: ) 270: logEvent('tengu_agent_parse_error', { 271: error: 272: errorMsg as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 273: location: 274: source as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 275: }) 276: return null 277: } 278: return agent 279: }) 280: .filter(agent => agent !== null) 281: let pluginAgentsPromise = loadPluginAgents() 282: if (feature('AGENT_MEMORY_SNAPSHOT') && isAutoMemoryEnabled()) { 283: const [pluginAgents_] = await Promise.all([ 284: pluginAgentsPromise, 285: initializeAgentMemorySnapshots(customAgents), 286: ]) 287: pluginAgentsPromise = Promise.resolve(pluginAgents_) 288: } 289: const pluginAgents = await pluginAgentsPromise 290: const builtInAgents = getBuiltInAgents() 291: const allAgentsList: AgentDefinition[] = [ 292: ...builtInAgents, 293: ...pluginAgents, 294: ...customAgents, 295: ] 296: const activeAgents = getActiveAgentsFromList(allAgentsList) 297: for (const agent of activeAgents) { 298: if (agent.color) { 299: setAgentColor(agent.agentType, agent.color) 300: } 301: } 302: return { 303: activeAgents, 304: allAgents: allAgentsList, 305: failedFiles: failedFiles.length > 0 ? failedFiles : undefined, 306: } 307: } catch (error) { 308: const errorMessage = 309: error instanceof Error ? error.message : String(error) 310: logForDebugging(`Error loading agent definitions: ${errorMessage}`) 311: logError(error) 312: const builtInAgents = getBuiltInAgents() 313: return { 314: activeAgents: builtInAgents, 315: allAgents: builtInAgents, 316: failedFiles: [{ path: 'unknown', error: errorMessage }], 317: } 318: } 319: }, 320: ) 321: export function clearAgentDefinitionsCache(): void { 322: getAgentDefinitionsWithOverrides.cache.clear?.() 323: clearPluginAgentCache() 324: } 325: function getParseError(frontmatter: Record<string, unknown>): string { 326: const agentType = frontmatter['name'] 327: const description = frontmatter['description'] 328: if (!agentType || typeof agentType !== 'string') { 329: return 'Missing required "name" field in frontmatter' 330: } 331: if (!description || typeof description !== 'string') { 332: return 'Missing required "description" field in frontmatter' 333: } 334: return 'Unknown parsing error' 335: } 336: function parseHooksFromFrontmatter( 337: frontmatter: Record<string, unknown>, 338: agentType: string, 339: ): HooksSettings | undefined { 340: if (!frontmatter.hooks) { 341: return undefined 342: } 343: const result = HooksSchema().safeParse(frontmatter.hooks) 344: if (!result.success) { 345: logForDebugging( 346: `Invalid hooks in agent '${agentType}': ${result.error.message}`, 347: ) 348: return undefined 349: } 350: return result.data 351: } 352: export function parseAgentFromJson( 353: name: string, 354: definition: unknown, 355: source: SettingSource = 'flagSettings', 356: ): CustomAgentDefinition | null { 357: try { 358: const parsed = AgentJsonSchema().parse(definition) 359: let tools = parseAgentToolsFromFrontmatter(parsed.tools) 360: if (isAutoMemoryEnabled() && parsed.memory && tools !== undefined) { 361: const toolSet = new Set(tools) 362: for (const tool of [ 363: FILE_WRITE_TOOL_NAME, 364: FILE_EDIT_TOOL_NAME, 365: FILE_READ_TOOL_NAME, 366: ]) { 367: if (!toolSet.has(tool)) { 368: tools = [...tools, tool] 369: } 370: } 371: } 372: const disallowedTools = 373: parsed.disallowedTools !== undefined 374: ? parseAgentToolsFromFrontmatter(parsed.disallowedTools) 375: : undefined 376: const systemPrompt = parsed.prompt 377: const agent: CustomAgentDefinition = { 378: agentType: name, 379: whenToUse: parsed.description, 380: ...(tools !== undefined ? { tools } : {}), 381: ...(disallowedTools !== undefined ? { disallowedTools } : {}), 382: getSystemPrompt: () => { 383: if (isAutoMemoryEnabled() && parsed.memory) { 384: return ( 385: systemPrompt + '\n\n' + loadAgentMemoryPrompt(name, parsed.memory) 386: ) 387: } 388: return systemPrompt 389: }, 390: source, 391: ...(parsed.model ? { model: parsed.model } : {}), 392: ...(parsed.effort !== undefined ? { effort: parsed.effort } : {}), 393: ...(parsed.permissionMode 394: ? { permissionMode: parsed.permissionMode } 395: : {}), 396: ...(parsed.mcpServers && parsed.mcpServers.length > 0 397: ? { mcpServers: parsed.mcpServers } 398: : {}), 399: ...(parsed.hooks ? { hooks: parsed.hooks } : {}), 400: ...(parsed.maxTurns !== undefined ? { maxTurns: parsed.maxTurns } : {}), 401: ...(parsed.skills && parsed.skills.length > 0 402: ? { skills: parsed.skills } 403: : {}), 404: ...(parsed.initialPrompt ? { initialPrompt: parsed.initialPrompt } : {}), 405: ...(parsed.background ? { background: parsed.background } : {}), 406: ...(parsed.memory ? { memory: parsed.memory } : {}), 407: ...(parsed.isolation ? { isolation: parsed.isolation } : {}), 408: } 409: return agent 410: } catch (error) { 411: const errorMessage = error instanceof Error ? error.message : String(error) 412: logForDebugging(`Error parsing agent '${name}' from JSON: ${errorMessage}`) 413: logError(error) 414: return null 415: } 416: } 417: export function parseAgentsFromJson( 418: agentsJson: unknown, 419: source: SettingSource = 'flagSettings', 420: ): AgentDefinition[] { 421: try { 422: const parsed = AgentsJsonSchema().parse(agentsJson) 423: return Object.entries(parsed) 424: .map(([name, def]) => parseAgentFromJson(name, def, source)) 425: .filter((agent): agent is CustomAgentDefinition => agent !== null) 426: } catch (error) { 427: const errorMessage = error instanceof Error ? error.message : String(error) 428: logForDebugging(`Error parsing agents from JSON: ${errorMessage}`) 429: logError(error) 430: return [] 431: } 432: } 433: export function parseAgentFromMarkdown( 434: filePath: string, 435: baseDir: string, 436: frontmatter: Record<string, unknown>, 437: content: string, 438: source: SettingSource, 439: ): CustomAgentDefinition | null { 440: try { 441: const agentType = frontmatter['name'] 442: let whenToUse = frontmatter['description'] as string 443: if (!agentType || typeof agentType !== 'string') { 444: return null 445: } 446: if (!whenToUse || typeof whenToUse !== 'string') { 447: logForDebugging( 448: `Agent file ${filePath} is missing required 'description' in frontmatter`, 449: ) 450: return null 451: } 452: whenToUse = whenToUse.replace(/\\n/g, '\n') 453: const color = frontmatter['color'] as AgentColorName | undefined 454: const modelRaw = frontmatter['model'] 455: let model: string | undefined 456: if (typeof modelRaw === 'string' && modelRaw.trim().length > 0) { 457: const trimmed = modelRaw.trim() 458: model = trimmed.toLowerCase() === 'inherit' ? 'inherit' : trimmed 459: } 460: const backgroundRaw = frontmatter['background'] 461: if ( 462: backgroundRaw !== undefined && 463: backgroundRaw !== 'true' && 464: backgroundRaw !== 'false' && 465: backgroundRaw !== true && 466: backgroundRaw !== false 467: ) { 468: logForDebugging( 469: `Agent file ${filePath} has invalid background value '${backgroundRaw}'. Must be 'true', 'false', or omitted.`, 470: ) 471: } 472: const background = 473: backgroundRaw === 'true' || backgroundRaw === true ? true : undefined 474: const VALID_MEMORY_SCOPES: AgentMemoryScope[] = ['user', 'project', 'local'] 475: const memoryRaw = frontmatter['memory'] as string | undefined 476: let memory: AgentMemoryScope | undefined 477: if (memoryRaw !== undefined) { 478: if (VALID_MEMORY_SCOPES.includes(memoryRaw as AgentMemoryScope)) { 479: memory = memoryRaw as AgentMemoryScope 480: } else { 481: logForDebugging( 482: `Agent file ${filePath} has invalid memory value '${memoryRaw}'. Valid options: ${VALID_MEMORY_SCOPES.join(', ')}`, 483: ) 484: } 485: } 486: type IsolationMode = 'worktree' | 'remote' 487: const VALID_ISOLATION_MODES: readonly IsolationMode[] = 488: process.env.USER_TYPE === 'ant' ? ['worktree', 'remote'] : ['worktree'] 489: const isolationRaw = frontmatter['isolation'] as string | undefined 490: let isolation: IsolationMode | undefined 491: if (isolationRaw !== undefined) { 492: if (VALID_ISOLATION_MODES.includes(isolationRaw as IsolationMode)) { 493: isolation = isolationRaw as IsolationMode 494: } else { 495: logForDebugging( 496: `Agent file ${filePath} has invalid isolation value '${isolationRaw}'. Valid options: ${VALID_ISOLATION_MODES.join(', ')}`, 497: ) 498: } 499: } 500: const effortRaw = frontmatter['effort'] 501: const parsedEffort = 502: effortRaw !== undefined ? parseEffortValue(effortRaw) : undefined 503: if (effortRaw !== undefined && parsedEffort === undefined) { 504: logForDebugging( 505: `Agent file ${filePath} has invalid effort '${effortRaw}'. Valid options: ${EFFORT_LEVELS.join(', ')} or an integer`, 506: ) 507: } 508: const permissionModeRaw = frontmatter['permissionMode'] as 509: | string 510: | undefined 511: const isValidPermissionMode = 512: permissionModeRaw && 513: (PERMISSION_MODES as readonly string[]).includes(permissionModeRaw) 514: if (permissionModeRaw && !isValidPermissionMode) { 515: const errorMsg = `Agent file ${filePath} has invalid permissionMode '${permissionModeRaw}'. Valid options: ${PERMISSION_MODES.join(', ')}` 516: logForDebugging(errorMsg) 517: } 518: const maxTurnsRaw = frontmatter['maxTurns'] 519: const maxTurns = parsePositiveIntFromFrontmatter(maxTurnsRaw) 520: if (maxTurnsRaw !== undefined && maxTurns === undefined) { 521: logForDebugging( 522: `Agent file ${filePath} has invalid maxTurns '${maxTurnsRaw}'. Must be a positive integer.`, 523: ) 524: } 525: const filename = basename(filePath, '.md') 526: let tools = parseAgentToolsFromFrontmatter(frontmatter['tools']) 527: if (isAutoMemoryEnabled() && memory && tools !== undefined) { 528: const toolSet = new Set(tools) 529: for (const tool of [ 530: FILE_WRITE_TOOL_NAME, 531: FILE_EDIT_TOOL_NAME, 532: FILE_READ_TOOL_NAME, 533: ]) { 534: if (!toolSet.has(tool)) { 535: tools = [...tools, tool] 536: } 537: } 538: } 539: const disallowedToolsRaw = frontmatter['disallowedTools'] 540: const disallowedTools = 541: disallowedToolsRaw !== undefined 542: ? parseAgentToolsFromFrontmatter(disallowedToolsRaw) 543: : undefined 544: const skills = parseSlashCommandToolsFromFrontmatter(frontmatter['skills']) 545: const initialPromptRaw = frontmatter['initialPrompt'] 546: const initialPrompt = 547: typeof initialPromptRaw === 'string' && initialPromptRaw.trim() 548: ? initialPromptRaw 549: : undefined 550: const mcpServersRaw = frontmatter['mcpServers'] 551: let mcpServers: AgentMcpServerSpec[] | undefined 552: if (Array.isArray(mcpServersRaw)) { 553: mcpServers = mcpServersRaw 554: .map(item => { 555: const result = AgentMcpServerSpecSchema().safeParse(item) 556: if (result.success) { 557: return result.data 558: } 559: logForDebugging( 560: `Agent file ${filePath} has invalid mcpServers item: ${jsonStringify(item)}. Error: ${result.error.message}`, 561: ) 562: return null 563: }) 564: .filter((item): item is AgentMcpServerSpec => item !== null) 565: } 566: const hooks = parseHooksFromFrontmatter(frontmatter, agentType) 567: const systemPrompt = content.trim() 568: const agentDef: CustomAgentDefinition = { 569: baseDir, 570: agentType: agentType, 571: whenToUse: whenToUse, 572: ...(tools !== undefined ? { tools } : {}), 573: ...(disallowedTools !== undefined ? { disallowedTools } : {}), 574: ...(skills !== undefined ? { skills } : {}), 575: ...(initialPrompt !== undefined ? { initialPrompt } : {}), 576: ...(mcpServers !== undefined && mcpServers.length > 0 577: ? { mcpServers } 578: : {}), 579: ...(hooks !== undefined ? { hooks } : {}), 580: getSystemPrompt: () => { 581: if (isAutoMemoryEnabled() && memory) { 582: const memoryPrompt = loadAgentMemoryPrompt(agentType, memory) 583: return systemPrompt + '\n\n' + memoryPrompt 584: } 585: return systemPrompt 586: }, 587: source, 588: filename, 589: ...(color && typeof color === 'string' && AGENT_COLORS.includes(color) 590: ? { color } 591: : {}), 592: ...(model !== undefined ? { model } : {}), 593: ...(parsedEffort !== undefined ? { effort: parsedEffort } : {}), 594: ...(isValidPermissionMode 595: ? { permissionMode: permissionModeRaw as PermissionMode } 596: : {}), 597: ...(maxTurns !== undefined ? { maxTurns } : {}), 598: ...(background ? { background } : {}), 599: ...(memory ? { memory } : {}), 600: ...(isolation ? { isolation } : {}), 601: } 602: return agentDef 603: } catch (error) { 604: const errorMessage = error instanceof Error ? error.message : String(error) 605: logForDebugging(`Error parsing agent from ${filePath}: ${errorMessage}`) 606: logError(error) 607: return null 608: } 609: }

File: src/tools/AgentTool/prompt.ts

typescript 1: import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js' 2: import { getSubscriptionType } from '../../utils/auth.js' 3: import { hasEmbeddedSearchTools } from '../../utils/embeddedTools.js' 4: import { isEnvDefinedFalsy, isEnvTruthy } from '../../utils/envUtils.js' 5: import { isTeammate } from '../../utils/teammate.js' 6: import { isInProcessTeammate } from '../../utils/teammateContext.js' 7: import { FILE_READ_TOOL_NAME } from '../FileReadTool/prompt.js' 8: import { FILE_WRITE_TOOL_NAME } from '../FileWriteTool/prompt.js' 9: import { GLOB_TOOL_NAME } from '../GlobTool/prompt.js' 10: import { SEND_MESSAGE_TOOL_NAME } from '../SendMessageTool/constants.js' 11: import { AGENT_TOOL_NAME } from './constants.js' 12: import { isForkSubagentEnabled } from './forkSubagent.js' 13: import type { AgentDefinition } from './loadAgentsDir.js' 14: function getToolsDescription(agent: AgentDefinition): string { 15: const { tools, disallowedTools } = agent 16: const hasAllowlist = tools && tools.length > 0 17: const hasDenylist = disallowedTools && disallowedTools.length > 0 18: if (hasAllowlist && hasDenylist) { 19: const denySet = new Set(disallowedTools) 20: const effectiveTools = tools.filter(t => !denySet.has(t)) 21: if (effectiveTools.length === 0) { 22: return 'None' 23: } 24: return effectiveTools.join(', ') 25: } else if (hasAllowlist) { 26: return tools.join(', ') 27: } else if (hasDenylist) { 28: return `All tools except ${disallowedTools.join(', ')}` 29: } 30: return 'All tools' 31: } 32: export function formatAgentLine(agent: AgentDefinition): string { 33: const toolsDescription = getToolsDescription(agent) 34: return `- ${agent.agentType}: ${agent.whenToUse} (Tools: ${toolsDescription})` 35: } 36: export function shouldInjectAgentListInMessages(): boolean { 37: if (isEnvTruthy(process.env.CLAUDE_CODE_AGENT_LIST_IN_MESSAGES)) return true 38: if (isEnvDefinedFalsy(process.env.CLAUDE_CODE_AGENT_LIST_IN_MESSAGES)) 39: return false 40: return getFeatureValue_CACHED_MAY_BE_STALE('tengu_agent_list_attach', false) 41: } 42: export async function getPrompt( 43: agentDefinitions: AgentDefinition[], 44: isCoordinator?: boolean, 45: allowedAgentTypes?: string[], 46: ): Promise<string> { 47: const effectiveAgents = allowedAgentTypes 48: ? agentDefinitions.filter(a => allowedAgentTypes.includes(a.agentType)) 49: : agentDefinitions 50: const forkEnabled = isForkSubagentEnabled() 51: const whenToForkSection = forkEnabled 52: ? ` 53: ## When to fork 54: Fork yourself (omit \`subagent_type\`) when the intermediate tool output isn't worth keeping in your context. The criterion is qualitative \u2014 "will I need this output again" \u2014 not task size. 55: - **Research**: fork open-ended questions. If research can be broken into independent questions, launch parallel forks in one message. A fork beats a fresh subagent for this \u2014 it inherits context and shares your cache. 56: - **Implementation**: prefer to fork implementation work that requires more than a couple of edits. Do research before jumping to implementation. 57: Forks are cheap because they share your prompt cache. Don't set \`model\` on a fork \u2014 a different model can't reuse the parent's cache. Pass a short \`name\` (one or two words, lowercase) so the user can see the fork in the teams panel and steer it mid-run. 58: **Don't peek.** The tool result includes an \`output_file\` path — do not Read or tail it unless the user explicitly asks for a progress check. You get a completion notification; trust it. Reading the transcript mid-flight pulls the fork's tool noise into your context, which defeats the point of forking. 59: **Don't race.** After launching, you know nothing about what the fork found. Never fabricate or predict fork results in any format — not as prose, summary, or structured output. The notification arrives as a user-role message in a later turn; it is never something you write yourself. If the user asks a follow-up before the notification lands, tell them the fork is still running — give status, not a guess. 60: **Writing a fork prompt.** Since the fork inherits your context, the prompt is a *directive* — what to do, not what the situation is. Be specific about scope: what's in, what's out, what another agent is handling. Don't re-explain background. 61: ` 62: : '' 63: const writingThePromptSection = ` 64: ## Writing the prompt 65: ${forkEnabled ? 'When spawning a fresh agent (with a `subagent_type`), it starts with zero context. ' : ''}Brief the agent like a smart colleague who just walked into the room — it hasn't seen this conversation, doesn't know what you've tried, doesn't understand why this task matters. 66: - Explain what you're trying to accomplish and why. 67: - Describe what you've already learned or ruled out. 68: - Give enough context about the surrounding problem that the agent can make judgment calls rather than just following a narrow instruction. 69: - If you need a short response, say so ("report in under 200 words"). 70: - Lookups: hand over the exact command. Investigations: hand over the question — prescribed steps become dead weight when the premise is wrong. 71: ${forkEnabled ? 'For fresh agents, terse' : 'Terse'} command-style prompts produce shallow, generic work. 72: **Never delegate understanding.** Don't write "based on your findings, fix the bug" or "based on the research, implement it." Those phrases push synthesis onto the agent instead of doing it yourself. Write prompts that prove you understood: include file paths, line numbers, what specifically to change. 73: ` 74: const forkExamples = `Example usage: 75: <example> 76: user: "What's left on this branch before we can ship?" 77: assistant: <thinking>Forking this \u2014 it's a survey question. I want the punch list, not the git output in my context.</thinking> 78: ${AGENT_TOOL_NAME}({ 79: name: "ship-audit", 80: description: "Branch ship-readiness audit", 81: prompt: "Audit what's left before this branch can ship. Check: uncommitted changes, commits ahead of main, whether tests exist, whether the GrowthBook gate is wired up, whether CI-relevant files changed. Report a punch list \u2014 done vs. missing. Under 200 words." 82: }) 83: assistant: Ship-readiness audit running. 84: <commentary> 85: Turn ends here. The coordinator knows nothing about the findings yet. What follows is a SEPARATE turn \u2014 the notification arrives from outside, as a user-role message. It is not something the coordinator writes. 86: </commentary> 87: [later turn \u2014 notification arrives as user message] 88: assistant: Audit's back. Three blockers: no tests for the new prompt path, GrowthBook gate wired but not in build_flags.yaml, and one uncommitted file. 89: </example> 90: <example> 91: user: "so is the gate wired up or not" 92: <commentary> 93: User asks mid-wait. The audit fork was launched to answer exactly this, and it hasn't returned. The coordinator does not have this answer. Give status, not a fabricated result. 94: </commentary> 95: assistant: Still waiting on the audit \u2014 that's one of the things it's checking. Should land shortly. 96: </example> 97: <example> 98: user: "Can you get a second opinion on whether this migration is safe?" 99: assistant: <thinking>I'll ask the code-reviewer agent — it won't see my analysis, so it can give an independent read.</thinking> 100: <commentary> 101: A subagent_type is specified, so the agent starts fresh. It needs full context in the prompt. The briefing explains what to assess and why. 102: </commentary> 103: ${AGENT_TOOL_NAME}({ 104: name: "migration-review", 105: description: "Independent migration review", 106: subagent_type: "code-reviewer", 107: prompt: "Review migration 0042_user_schema.sql for safety. Context: we're adding a NOT NULL column to a 50M-row table. Existing rows get a backfill default. I want a second opinion on whether the backfill approach is safe under concurrent writes — I've checked locking behavior but want independent verification. Report: is this safe, and if not, what specifically breaks?" 108: }) 109: </example> 110: ` 111: const currentExamples = `Example usage: 112: <example_agent_descriptions> 113: "test-runner": use this agent after you are done writing code to run tests 114: "greeting-responder": use this agent to respond to user greetings with a friendly joke 115: </example_agent_descriptions> 116: <example> 117: user: "Please write a function that checks if a number is prime" 118: assistant: I'm going to use the ${FILE_WRITE_TOOL_NAME} tool to write the following code: 119: <code> 120: function isPrime(n) { 121: if (n <= 1) return false 122: for (let i = 2; i * i <= n; i++) { 123: if (n % i === 0) return false 124: } 125: return true 126: } 127: </code> 128: <commentary> 129: Since a significant piece of code was written and the task was completed, now use the test-runner agent to run the tests 130: </commentary> 131: assistant: Uses the ${AGENT_TOOL_NAME} tool to launch the test-runner agent 132: </example> 133: <example> 134: user: "Hello" 135: <commentary> 136: Since the user is greeting, use the greeting-responder agent to respond with a friendly joke 137: </commentary> 138: assistant: "I'm going to use the ${AGENT_TOOL_NAME} tool to launch the greeting-responder agent" 139: </example> 140: ` 141: // When the gate is on, the agent list lives in an agent_listing_delta 142: // attachment (see attachments.ts) instead of inline here. This keeps the 143: // tool description static across MCP/plugin/permission changes so the 144: // tools-block prompt cache doesn't bust every time an agent loads. 145: const listViaAttachment = shouldInjectAgentListInMessages() 146: const agentListSection = listViaAttachment 147: ? `Available agent types are listed in <system-reminder> messages in the conversation.` 148: : `Available agent types and the tools they have access to: 149: ${effectiveAgents.map(agent => formatAgentLine(agent)).join('\n')}` 150: // Shared core prompt used by both coordinator and non-coordinator modes 151: const shared = `Launch a new agent to handle complex, multi-step tasks autonomously. 152: The ${AGENT_TOOL_NAME} tool launches specialized agents (subprocesses) that autonomously handle complex tasks. Each agent type has specific capabilities and tools available to it. 153: ${agentListSection} 154: ${ 155: forkEnabled 156: ? `When using the ${AGENT_TOOL_NAME} tool, specify a subagent_type to use a specialized agent, or omit it to fork yourself — a fork inherits your full conversation context.` 157: : `When using the ${AGENT_TOOL_NAME} tool, specify a subagent_type parameter to select which agent type to use. If omitted, the general-purpose agent is used.` 158: }` 159: // Coordinator mode gets the slim prompt -- the coordinator system prompt 160: // already covers usage notes, examples, and when-not-to-use guidance. 161: if (isCoordinator) { 162: return shared 163: } 164: // Ant-native builds alias find/grep to embedded bfs/ugrep and remove the 165: // dedicated Glob/Grep tools, so point at find via Bash instead. 166: const embedded = hasEmbeddedSearchTools() 167: const fileSearchHint = embedded 168: ? '`find` via the Bash tool' 169: : `the ${GLOB_TOOL_NAME} tool` 170: const contentSearchHint = embedded 171: ? '`grep` via the Bash tool' 172: : `the ${GLOB_TOOL_NAME} tool` 173: const whenNotToUseSection = forkEnabled 174: ? '' 175: : ` 176: When NOT to use the ${AGENT_TOOL_NAME} tool: 177: - If you want to read a specific file path, use the ${FILE_READ_TOOL_NAME} tool or ${fileSearchHint} instead of the ${AGENT_TOOL_NAME} tool, to find the match more quickly 178: - If you are searching for a specific class definition like "class Foo", use ${contentSearchHint} instead, to find the match more quickly 179: - If you are searching for code within a specific file or set of 2-3 files, use the ${FILE_READ_TOOL_NAME} tool instead of the ${AGENT_TOOL_NAME} tool, to find the match more quickly 180: - Other tasks that are not related to the agent descriptions above 181: ` 182: // When listing via attachment, the "launch multiple agents" note is in the 183: // attachment message (conditioned on subscription there). When inline, keep 184: // the existing per-call getSubscriptionType() check. 185: const concurrencyNote = 186: !listViaAttachment && getSubscriptionType() !== 'pro' 187: ? ` 188: - Launch multiple agents concurrently whenever possible, to maximize performance; to do that, use a single message with multiple tool uses` 189: : '' 190: // Non-coordinator gets the full prompt with all sections 191: return `${shared} 192: ${whenNotToUseSection} 193: Usage notes: 194: - Always include a short description (3-5 words) summarizing what the agent will do${concurrencyNote} 195: - When the agent is done, it will return a single message back to you. The result returned by the agent is not visible to the user. To show the user the result, you should send a text message back to the user with a concise summary of the result.${ 196: // eslint-disable-next-line custom-rules/no-process-env-top-level 197: !isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_BACKGROUND_TASKS) && 198: !isInProcessTeammate() && 199: !forkEnabled 200: ? ` 201: - You can optionally run agents in the background using the run_in_background parameter. When an agent runs in the background, you will be automatically notified when it completes — do NOT sleep, poll, or proactively check on its progress. Continue with other work or respond to the user instead. 202: - **Foreground vs background**: Use foreground (default) when you need the agent's results before you can proceed — e.g., research agents whose findings inform your next steps. Use background when you have genuinely independent work to do in parallel.` 203: : '' 204: } 205: - To continue a previously spawned agent, use ${SEND_MESSAGE_TOOL_NAME} with the agent's ID or name as the \`to\` field. The agent resumes with its full context preserved. ${forkEnabled ? 'Each fresh Agent invocation with a subagent_type starts without context — provide a complete task description.' : 'Each Agent invocation starts fresh — provide a complete task description.'} 206: - The agent's outputs should generally be trusted 207: - Clearly tell the agent whether you expect it to write code or just to do research (search, file reads, web fetches, etc.)${forkEnabled ? '' : ", since it is not aware of the user's intent"} 208: - If the agent description mentions that it should be used proactively, then you should try your best to use it without the user having to ask for it first. Use your judgement. 209: - If the user specifies that they want you to run agents "in parallel", you MUST send a single message with multiple ${AGENT_TOOL_NAME} tool use content blocks. For example, if you need to launch both a build-validator agent and a test-runner agent in parallel, send a single message with both tool calls. 210: - You can optionally set \`isolation: "worktree"\` to run the agent in a temporary git worktree, giving it an isolated copy of the repository. The worktree is automatically cleaned up if the agent makes no changes; if changes are made, the worktree path and branch are returned in the result.${ 211: process.env.USER_TYPE === 'ant' 212: ? `\n- You can set \`isolation: "remote"\` to run the agent in a remote CCR environment. This is always a background task; you'll be notified when it completes. Use for long-running tasks that need a fresh sandbox.` 213: : '' 214: }${ 215: isInProcessTeammate() 216: ? ` 217: - The run_in_background, name, team_name, and mode parameters are not available in this context. Only synchronous subagents are supported.` 218: : isTeammate() 219: ? ` 220: - The name, team_name, and mode parameters are not available in this context — teammates cannot spawn other teammates. Omit them to spawn a subagent.` 221: : '' 222: }${whenToForkSection}${writingThePromptSection} 223: ${forkEnabled ? forkExamples : currentExamples}` 224: }

File: src/tools/AgentTool/resumeAgent.ts

typescript 1: import { promises as fsp } from 'fs' 2: import { getSdkAgentProgressSummariesEnabled } from '../../bootstrap/state.js' 3: import { getSystemPrompt } from '../../constants/prompts.js' 4: import { isCoordinatorMode } from '../../coordinator/coordinatorMode.js' 5: import type { CanUseToolFn } from '../../hooks/useCanUseTool.js' 6: import type { ToolUseContext } from '../../Tool.js' 7: import { registerAsyncAgent } from '../../tasks/LocalAgentTask/LocalAgentTask.js' 8: import { assembleToolPool } from '../../tools.js' 9: import { asAgentId } from '../../types/ids.js' 10: import { runWithAgentContext } from '../../utils/agentContext.js' 11: import { runWithCwdOverride } from '../../utils/cwd.js' 12: import { logForDebugging } from '../../utils/debug.js' 13: import { 14: createUserMessage, 15: filterOrphanedThinkingOnlyMessages, 16: filterUnresolvedToolUses, 17: filterWhitespaceOnlyAssistantMessages, 18: } from '../../utils/messages.js' 19: import { getAgentModel } from '../../utils/model/agent.js' 20: import { getQuerySourceForAgent } from '../../utils/promptCategory.js' 21: import { 22: getAgentTranscript, 23: readAgentMetadata, 24: } from '../../utils/sessionStorage.js' 25: import { buildEffectiveSystemPrompt } from '../../utils/systemPrompt.js' 26: import type { SystemPrompt } from '../../utils/systemPromptType.js' 27: import { getTaskOutputPath } from '../../utils/task/diskOutput.js' 28: import { getParentSessionId } from '../../utils/teammate.js' 29: import { reconstructForSubagentResume } from '../../utils/toolResultStorage.js' 30: import { runAsyncAgentLifecycle } from './agentToolUtils.js' 31: import { GENERAL_PURPOSE_AGENT } from './built-in/generalPurposeAgent.js' 32: import { FORK_AGENT, isForkSubagentEnabled } from './forkSubagent.js' 33: import type { AgentDefinition } from './loadAgentsDir.js' 34: import { isBuiltInAgent } from './loadAgentsDir.js' 35: import { runAgent } from './runAgent.js' 36: export type ResumeAgentResult = { 37: agentId: string 38: description: string 39: outputFile: string 40: } 41: export async function resumeAgentBackground({ 42: agentId, 43: prompt, 44: toolUseContext, 45: canUseTool, 46: invokingRequestId, 47: }: { 48: agentId: string 49: prompt: string 50: toolUseContext: ToolUseContext 51: canUseTool: CanUseToolFn 52: invokingRequestId?: string 53: }): Promise<ResumeAgentResult> { 54: const startTime = Date.now() 55: const appState = toolUseContext.getAppState() 56: const rootSetAppState = 57: toolUseContext.setAppStateForTasks ?? toolUseContext.setAppState 58: const permissionMode = appState.toolPermissionContext.mode 59: const [transcript, meta] = await Promise.all([ 60: getAgentTranscript(asAgentId(agentId)), 61: readAgentMetadata(asAgentId(agentId)), 62: ]) 63: if (!transcript) { 64: throw new Error(`No transcript found for agent ID: ${agentId}`) 65: } 66: const resumedMessages = filterWhitespaceOnlyAssistantMessages( 67: filterOrphanedThinkingOnlyMessages( 68: filterUnresolvedToolUses(transcript.messages), 69: ), 70: ) 71: const resumedReplacementState = reconstructForSubagentResume( 72: toolUseContext.contentReplacementState, 73: resumedMessages, 74: transcript.contentReplacements, 75: ) 76: const resumedWorktreePath = meta?.worktreePath 77: ? await fsp.stat(meta.worktreePath).then( 78: s => (s.isDirectory() ? meta.worktreePath : undefined), 79: () => { 80: logForDebugging( 81: `Resumed worktree ${meta.worktreePath} no longer exists; falling back to parent cwd`, 82: ) 83: return undefined 84: }, 85: ) 86: : undefined 87: if (resumedWorktreePath) { 88: const now = new Date() 89: await fsp.utimes(resumedWorktreePath, now, now) 90: } 91: let selectedAgent: AgentDefinition 92: let isResumedFork = false 93: if (meta?.agentType === FORK_AGENT.agentType) { 94: selectedAgent = FORK_AGENT 95: isResumedFork = true 96: } else if (meta?.agentType) { 97: const found = toolUseContext.options.agentDefinitions.activeAgents.find( 98: a => a.agentType === meta.agentType, 99: ) 100: selectedAgent = found ?? GENERAL_PURPOSE_AGENT 101: } else { 102: selectedAgent = GENERAL_PURPOSE_AGENT 103: } 104: const uiDescription = meta?.description ?? '(resumed)' 105: let forkParentSystemPrompt: SystemPrompt | undefined 106: if (isResumedFork) { 107: if (toolUseContext.renderedSystemPrompt) { 108: forkParentSystemPrompt = toolUseContext.renderedSystemPrompt 109: } else { 110: const mainThreadAgentDefinition = appState.agent 111: ? appState.agentDefinitions.activeAgents.find( 112: a => a.agentType === appState.agent, 113: ) 114: : undefined 115: const additionalWorkingDirectories = Array.from( 116: appState.toolPermissionContext.additionalWorkingDirectories.keys(), 117: ) 118: const defaultSystemPrompt = await getSystemPrompt( 119: toolUseContext.options.tools, 120: toolUseContext.options.mainLoopModel, 121: additionalWorkingDirectories, 122: toolUseContext.options.mcpClients, 123: ) 124: forkParentSystemPrompt = buildEffectiveSystemPrompt({ 125: mainThreadAgentDefinition, 126: toolUseContext, 127: customSystemPrompt: toolUseContext.options.customSystemPrompt, 128: defaultSystemPrompt, 129: appendSystemPrompt: toolUseContext.options.appendSystemPrompt, 130: }) 131: } 132: if (!forkParentSystemPrompt) { 133: throw new Error( 134: 'Cannot resume fork agent: unable to reconstruct parent system prompt', 135: ) 136: } 137: } 138: const resolvedAgentModel = getAgentModel( 139: selectedAgent.model, 140: toolUseContext.options.mainLoopModel, 141: undefined, 142: permissionMode, 143: ) 144: const workerPermissionContext = { 145: ...appState.toolPermissionContext, 146: mode: selectedAgent.permissionMode ?? 'acceptEdits', 147: } 148: const workerTools = isResumedFork 149: ? toolUseContext.options.tools 150: : assembleToolPool(workerPermissionContext, appState.mcp.tools) 151: const runAgentParams: Parameters<typeof runAgent>[0] = { 152: agentDefinition: selectedAgent, 153: promptMessages: [ 154: ...resumedMessages, 155: createUserMessage({ content: prompt }), 156: ], 157: toolUseContext, 158: canUseTool, 159: isAsync: true, 160: querySource: getQuerySourceForAgent( 161: selectedAgent.agentType, 162: isBuiltInAgent(selectedAgent), 163: ), 164: model: undefined, 165: override: isResumedFork 166: ? { systemPrompt: forkParentSystemPrompt } 167: : undefined, 168: availableTools: workerTools, 169: forkContextMessages: undefined, 170: ...(isResumedFork && { useExactTools: true }), 171: worktreePath: resumedWorktreePath, 172: description: meta?.description, 173: contentReplacementState: resumedReplacementState, 174: } 175: const agentBackgroundTask = registerAsyncAgent({ 176: agentId, 177: description: uiDescription, 178: prompt, 179: selectedAgent, 180: setAppState: rootSetAppState, 181: toolUseId: toolUseContext.toolUseId, 182: }) 183: const metadata = { 184: prompt, 185: resolvedAgentModel, 186: isBuiltInAgent: isBuiltInAgent(selectedAgent), 187: startTime, 188: agentType: selectedAgent.agentType, 189: isAsync: true, 190: } 191: const asyncAgentContext = { 192: agentId, 193: parentSessionId: getParentSessionId(), 194: agentType: 'subagent' as const, 195: subagentName: selectedAgent.agentType, 196: isBuiltIn: isBuiltInAgent(selectedAgent), 197: invokingRequestId, 198: invocationKind: 'resume' as const, 199: invocationEmitted: false, 200: } 201: const wrapWithCwd = <T>(fn: () => T): T => 202: resumedWorktreePath ? runWithCwdOverride(resumedWorktreePath, fn) : fn() 203: void runWithAgentContext(asyncAgentContext, () => 204: wrapWithCwd(() => 205: runAsyncAgentLifecycle({ 206: taskId: agentBackgroundTask.agentId, 207: abortController: agentBackgroundTask.abortController!, 208: makeStream: onCacheSafeParams => 209: runAgent({ 210: ...runAgentParams, 211: override: { 212: ...runAgentParams.override, 213: agentId: asAgentId(agentBackgroundTask.agentId), 214: abortController: agentBackgroundTask.abortController!, 215: }, 216: onCacheSafeParams, 217: }), 218: metadata, 219: description: uiDescription, 220: toolUseContext, 221: rootSetAppState, 222: agentIdForCleanup: agentId, 223: enableSummarization: 224: isCoordinatorMode() || 225: isForkSubagentEnabled() || 226: getSdkAgentProgressSummariesEnabled(), 227: getWorktreeResult: async () => 228: resumedWorktreePath ? { worktreePath: resumedWorktreePath } : {}, 229: }), 230: ), 231: ) 232: return { 233: agentId, 234: description: uiDescription, 235: outputFile: getTaskOutputPath(agentId), 236: } 237: }

File: src/tools/AgentTool/runAgent.ts

typescript 1: import { feature } from 'bun:bundle' 2: import type { UUID } from 'crypto' 3: import { randomUUID } from 'crypto' 4: import uniqBy from 'lodash-es/uniqBy.js' 5: import { logForDebugging } from 'src/utils/debug.js' 6: import { getProjectRoot, getSessionId } from '../../bootstrap/state.js' 7: import { getCommand, getSkillToolCommands, hasCommand } from '../../commands.js' 8: import { 9: DEFAULT_AGENT_PROMPT, 10: enhanceSystemPromptWithEnvDetails, 11: } from '../../constants/prompts.js' 12: import type { QuerySource } from '../../constants/querySource.js' 13: import { getSystemContext, getUserContext } from '../../context.js' 14: import type { CanUseToolFn } from '../../hooks/useCanUseTool.js' 15: import { query } from '../../query.js' 16: import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js' 17: import { getDumpPromptsPath } from '../../services/api/dumpPrompts.js' 18: import { cleanupAgentTracking } from '../../services/api/promptCacheBreakDetection.js' 19: import { 20: connectToServer, 21: fetchToolsForClient, 22: } from '../../services/mcp/client.js' 23: import { getMcpConfigByName } from '../../services/mcp/config.js' 24: import type { 25: MCPServerConnection, 26: ScopedMcpServerConfig, 27: } from '../../services/mcp/types.js' 28: import type { Tool, Tools, ToolUseContext } from '../../Tool.js' 29: import { killShellTasksForAgent } from '../../tasks/LocalShellTask/killShellTasks.js' 30: import type { Command } from '../../types/command.js' 31: import type { AgentId } from '../../types/ids.js' 32: import type { 33: AssistantMessage, 34: Message, 35: ProgressMessage, 36: RequestStartEvent, 37: StreamEvent, 38: SystemCompactBoundaryMessage, 39: TombstoneMessage, 40: ToolUseSummaryMessage, 41: UserMessage, 42: } from '../../types/message.js' 43: import { createAttachmentMessage } from '../../utils/attachments.js' 44: import { AbortError } from '../../utils/errors.js' 45: import { getDisplayPath } from '../../utils/file.js' 46: import { 47: cloneFileStateCache, 48: createFileStateCacheWithSizeLimit, 49: READ_FILE_STATE_CACHE_SIZE, 50: } from '../../utils/fileStateCache.js' 51: import { 52: type CacheSafeParams, 53: createSubagentContext, 54: } from '../../utils/forkedAgent.js' 55: import { registerFrontmatterHooks } from '../../utils/hooks/registerFrontmatterHooks.js' 56: import { clearSessionHooks } from '../../utils/hooks/sessionHooks.js' 57: import { executeSubagentStartHooks } from '../../utils/hooks.js' 58: import { createUserMessage } from '../../utils/messages.js' 59: import { getAgentModel } from '../../utils/model/agent.js' 60: import type { ModelAlias } from '../../utils/model/aliases.js' 61: import { 62: clearAgentTranscriptSubdir, 63: recordSidechainTranscript, 64: setAgentTranscriptSubdir, 65: writeAgentMetadata, 66: } from '../../utils/sessionStorage.js' 67: import { 68: isRestrictedToPluginOnly, 69: isSourceAdminTrusted, 70: } from '../../utils/settings/pluginOnlyPolicy.js' 71: import { 72: asSystemPrompt, 73: type SystemPrompt, 74: } from '../../utils/systemPromptType.js' 75: import { 76: isPerfettoTracingEnabled, 77: registerAgent as registerPerfettoAgent, 78: unregisterAgent as unregisterPerfettoAgent, 79: } from '../../utils/telemetry/perfettoTracing.js' 80: import type { ContentReplacementState } from '../../utils/toolResultStorage.js' 81: import { createAgentId } from '../../utils/uuid.js' 82: import { resolveAgentTools } from './agentToolUtils.js' 83: import { type AgentDefinition, isBuiltInAgent } from './loadAgentsDir.js' 84: async function initializeAgentMcpServers( 85: agentDefinition: AgentDefinition, 86: parentClients: MCPServerConnection[], 87: ): Promise<{ 88: clients: MCPServerConnection[] 89: tools: Tools 90: cleanup: () => Promise<void> 91: }> { 92: if (!agentDefinition.mcpServers?.length) { 93: return { 94: clients: parentClients, 95: tools: [], 96: cleanup: async () => {}, 97: } 98: } 99: const agentIsAdminTrusted = isSourceAdminTrusted(agentDefinition.source) 100: if (isRestrictedToPluginOnly('mcp') && !agentIsAdminTrusted) { 101: logForDebugging( 102: `[Agent: ${agentDefinition.agentType}] Skipping MCP servers: strictPluginOnlyCustomization locks MCP to plugin-only (agent source: ${agentDefinition.source})`, 103: ) 104: return { 105: clients: parentClients, 106: tools: [], 107: cleanup: async () => {}, 108: } 109: } 110: const agentClients: MCPServerConnection[] = [] 111: const newlyCreatedClients: MCPServerConnection[] = [] 112: const agentTools: Tool[] = [] 113: for (const spec of agentDefinition.mcpServers) { 114: let config: ScopedMcpServerConfig | null = null 115: let name: string 116: let isNewlyCreated = false 117: if (typeof spec === 'string') { 118: name = spec 119: config = getMcpConfigByName(spec) 120: if (!config) { 121: logForDebugging( 122: `[Agent: ${agentDefinition.agentType}] MCP server not found: ${spec}`, 123: { level: 'warn' }, 124: ) 125: continue 126: } 127: } else { 128: const entries = Object.entries(spec) 129: if (entries.length !== 1) { 130: logForDebugging( 131: `[Agent: ${agentDefinition.agentType}] Invalid MCP server spec: expected exactly one key`, 132: { level: 'warn' }, 133: ) 134: continue 135: } 136: const [serverName, serverConfig] = entries[0]! 137: name = serverName 138: config = { 139: ...serverConfig, 140: scope: 'dynamic' as const, 141: } as ScopedMcpServerConfig 142: isNewlyCreated = true 143: } 144: const client = await connectToServer(name, config) 145: agentClients.push(client) 146: if (isNewlyCreated) { 147: newlyCreatedClients.push(client) 148: } 149: if (client.type === 'connected') { 150: const tools = await fetchToolsForClient(client) 151: agentTools.push(...tools) 152: logForDebugging( 153: `[Agent: ${agentDefinition.agentType}] Connected to MCP server '${name}' with ${tools.length} tools`, 154: ) 155: } else { 156: logForDebugging( 157: `[Agent: ${agentDefinition.agentType}] Failed to connect to MCP server '${name}': ${client.type}`, 158: { level: 'warn' }, 159: ) 160: } 161: } 162: const cleanup = async () => { 163: for (const client of newlyCreatedClients) { 164: if (client.type === 'connected') { 165: try { 166: await client.cleanup() 167: } catch (error) { 168: logForDebugging( 169: `[Agent: ${agentDefinition.agentType}] Error cleaning up MCP server '${client.name}': ${error}`, 170: { level: 'warn' }, 171: ) 172: } 173: } 174: } 175: } 176: return { 177: clients: [...parentClients, ...agentClients], 178: tools: agentTools, 179: cleanup, 180: } 181: } 182: type QueryMessage = 183: | StreamEvent 184: | RequestStartEvent 185: | Message 186: | ToolUseSummaryMessage 187: | TombstoneMessage 188: function isRecordableMessage( 189: msg: QueryMessage, 190: ): msg is 191: | AssistantMessage 192: | UserMessage 193: | ProgressMessage 194: | SystemCompactBoundaryMessage { 195: return ( 196: msg.type === 'assistant' || 197: msg.type === 'user' || 198: msg.type === 'progress' || 199: (msg.type === 'system' && 200: 'subtype' in msg && 201: msg.subtype === 'compact_boundary') 202: ) 203: } 204: export async function* runAgent({ 205: agentDefinition, 206: promptMessages, 207: toolUseContext, 208: canUseTool, 209: isAsync, 210: canShowPermissionPrompts, 211: forkContextMessages, 212: querySource, 213: override, 214: model, 215: maxTurns, 216: preserveToolUseResults, 217: availableTools, 218: allowedTools, 219: onCacheSafeParams, 220: contentReplacementState, 221: useExactTools, 222: worktreePath, 223: description, 224: transcriptSubdir, 225: onQueryProgress, 226: }: { 227: agentDefinition: AgentDefinition 228: promptMessages: Message[] 229: toolUseContext: ToolUseContext 230: canUseTool: CanUseToolFn 231: isAsync: boolean 232: canShowPermissionPrompts?: boolean 233: forkContextMessages?: Message[] 234: querySource: QuerySource 235: override?: { 236: userContext?: { [k: string]: string } 237: systemContext?: { [k: string]: string } 238: systemPrompt?: SystemPrompt 239: abortController?: AbortController 240: agentId?: AgentId 241: } 242: model?: ModelAlias 243: maxTurns?: number 244: preserveToolUseResults?: boolean 245: availableTools: Tools 246: allowedTools?: string[] 247: onCacheSafeParams?: (params: CacheSafeParams) => void 248: contentReplacementState?: ContentReplacementState 249: useExactTools?: boolean 250: worktreePath?: string 251: description?: string 252: transcriptSubdir?: string 253: onQueryProgress?: () => void 254: }): AsyncGenerator<Message, void> { 255: const appState = toolUseContext.getAppState() 256: const permissionMode = appState.toolPermissionContext.mode 257: const rootSetAppState = 258: toolUseContext.setAppStateForTasks ?? toolUseContext.setAppState 259: const resolvedAgentModel = getAgentModel( 260: agentDefinition.model, 261: toolUseContext.options.mainLoopModel, 262: model, 263: permissionMode, 264: ) 265: const agentId = override?.agentId ? override.agentId : createAgentId() 266: if (transcriptSubdir) { 267: setAgentTranscriptSubdir(agentId, transcriptSubdir) 268: } 269: if (isPerfettoTracingEnabled()) { 270: const parentId = toolUseContext.agentId ?? getSessionId() 271: registerPerfettoAgent(agentId, agentDefinition.agentType, parentId) 272: } 273: if (process.env.USER_TYPE === 'ant') { 274: logForDebugging( 275: `[Subagent ${agentDefinition.agentType}] API calls: ${getDisplayPath(getDumpPromptsPath(agentId))}`, 276: ) 277: } 278: const contextMessages: Message[] = forkContextMessages 279: ? filterIncompleteToolCalls(forkContextMessages) 280: : [] 281: const initialMessages: Message[] = [...contextMessages, ...promptMessages] 282: const agentReadFileState = 283: forkContextMessages !== undefined 284: ? cloneFileStateCache(toolUseContext.readFileState) 285: : createFileStateCacheWithSizeLimit(READ_FILE_STATE_CACHE_SIZE) 286: const [baseUserContext, baseSystemContext] = await Promise.all([ 287: override?.userContext ?? getUserContext(), 288: override?.systemContext ?? getSystemContext(), 289: ]) 290: const shouldOmitClaudeMd = 291: agentDefinition.omitClaudeMd && 292: !override?.userContext && 293: getFeatureValue_CACHED_MAY_BE_STALE('tengu_slim_subagent_claudemd', true) 294: const { claudeMd: _omittedClaudeMd, ...userContextNoClaudeMd } = 295: baseUserContext 296: const resolvedUserContext = shouldOmitClaudeMd 297: ? userContextNoClaudeMd 298: : baseUserContext 299: const { gitStatus: _omittedGitStatus, ...systemContextNoGit } = 300: baseSystemContext 301: const resolvedSystemContext = 302: agentDefinition.agentType === 'Explore' || 303: agentDefinition.agentType === 'Plan' 304: ? systemContextNoGit 305: : baseSystemContext 306: const agentPermissionMode = agentDefinition.permissionMode 307: const agentGetAppState = () => { 308: const state = toolUseContext.getAppState() 309: let toolPermissionContext = state.toolPermissionContext 310: if ( 311: agentPermissionMode && 312: state.toolPermissionContext.mode !== 'bypassPermissions' && 313: state.toolPermissionContext.mode !== 'acceptEdits' && 314: !( 315: feature('TRANSCRIPT_CLASSIFIER') && 316: state.toolPermissionContext.mode === 'auto' 317: ) 318: ) { 319: toolPermissionContext = { 320: ...toolPermissionContext, 321: mode: agentPermissionMode, 322: } 323: } 324: const shouldAvoidPrompts = 325: canShowPermissionPrompts !== undefined 326: ? !canShowPermissionPrompts 327: : agentPermissionMode === 'bubble' 328: ? false 329: : isAsync 330: if (shouldAvoidPrompts) { 331: toolPermissionContext = { 332: ...toolPermissionContext, 333: shouldAvoidPermissionPrompts: true, 334: } 335: } 336: if (isAsync && !shouldAvoidPrompts) { 337: toolPermissionContext = { 338: ...toolPermissionContext, 339: awaitAutomatedChecksBeforeDialog: true, 340: } 341: } 342: if (allowedTools !== undefined) { 343: toolPermissionContext = { 344: ...toolPermissionContext, 345: alwaysAllowRules: { 346: cliArg: state.toolPermissionContext.alwaysAllowRules.cliArg, 347: session: [...allowedTools], 348: }, 349: } 350: } 351: const effortValue = 352: agentDefinition.effort !== undefined 353: ? agentDefinition.effort 354: : state.effortValue 355: if ( 356: toolPermissionContext === state.toolPermissionContext && 357: effortValue === state.effortValue 358: ) { 359: return state 360: } 361: return { 362: ...state, 363: toolPermissionContext, 364: effortValue, 365: } 366: } 367: const resolvedTools = useExactTools 368: ? availableTools 369: : resolveAgentTools(agentDefinition, availableTools, isAsync).resolvedTools 370: const additionalWorkingDirectories = Array.from( 371: appState.toolPermissionContext.additionalWorkingDirectories.keys(), 372: ) 373: const agentSystemPrompt = override?.systemPrompt 374: ? override.systemPrompt 375: : asSystemPrompt( 376: await getAgentSystemPrompt( 377: agentDefinition, 378: toolUseContext, 379: resolvedAgentModel, 380: additionalWorkingDirectories, 381: resolvedTools, 382: ), 383: ) 384: const agentAbortController = override?.abortController 385: ? override.abortController 386: : isAsync 387: ? new AbortController() 388: : toolUseContext.abortController 389: const additionalContexts: string[] = [] 390: for await (const hookResult of executeSubagentStartHooks( 391: agentId, 392: agentDefinition.agentType, 393: agentAbortController.signal, 394: )) { 395: if ( 396: hookResult.additionalContexts && 397: hookResult.additionalContexts.length > 0 398: ) { 399: additionalContexts.push(...hookResult.additionalContexts) 400: } 401: } 402: if (additionalContexts.length > 0) { 403: const contextMessage = createAttachmentMessage({ 404: type: 'hook_additional_context', 405: content: additionalContexts, 406: hookName: 'SubagentStart', 407: toolUseID: randomUUID(), 408: hookEvent: 'SubagentStart', 409: }) 410: initialMessages.push(contextMessage) 411: } 412: const hooksAllowedForThisAgent = 413: !isRestrictedToPluginOnly('hooks') || 414: isSourceAdminTrusted(agentDefinition.source) 415: if (agentDefinition.hooks && hooksAllowedForThisAgent) { 416: registerFrontmatterHooks( 417: rootSetAppState, 418: agentId, 419: agentDefinition.hooks, 420: `agent '${agentDefinition.agentType}'`, 421: true, 422: ) 423: } 424: const skillsToPreload = agentDefinition.skills ?? [] 425: if (skillsToPreload.length > 0) { 426: const allSkills = await getSkillToolCommands(getProjectRoot()) 427: const validSkills: Array<{ 428: skillName: string 429: skill: (typeof allSkills)[0] & { type: 'prompt' } 430: }> = [] 431: for (const skillName of skillsToPreload) { 432: const resolvedName = resolveSkillName( 433: skillName, 434: allSkills, 435: agentDefinition, 436: ) 437: if (!resolvedName) { 438: logForDebugging( 439: `[Agent: ${agentDefinition.agentType}] Warning: Skill '${skillName}' specified in frontmatter was not found`, 440: { level: 'warn' }, 441: ) 442: continue 443: } 444: const skill = getCommand(resolvedName, allSkills) 445: if (skill.type !== 'prompt') { 446: logForDebugging( 447: `[Agent: ${agentDefinition.agentType}] Warning: Skill '${skillName}' is not a prompt-based skill`, 448: { level: 'warn' }, 449: ) 450: continue 451: } 452: validSkills.push({ skillName, skill }) 453: } 454: const { formatSkillLoadingMetadata } = await import( 455: '../../utils/processUserInput/processSlashCommand.js' 456: ) 457: const loaded = await Promise.all( 458: validSkills.map(async ({ skillName, skill }) => ({ 459: skillName, 460: skill, 461: content: await skill.getPromptForCommand('', toolUseContext), 462: })), 463: ) 464: for (const { skillName, skill, content } of loaded) { 465: logForDebugging( 466: `[Agent: ${agentDefinition.agentType}] Preloaded skill '${skillName}'`, 467: ) 468: // Add command-message metadata so the UI shows which skill is loading 469: const metadata = formatSkillLoadingMetadata( 470: skillName, 471: skill.progressMessage, 472: ) 473: initialMessages.push( 474: createUserMessage({ 475: content: [{ type: 'text', text: metadata }, ...content], 476: isMeta: true, 477: }), 478: ) 479: } 480: } 481: const { 482: clients: mergedMcpClients, 483: tools: agentMcpTools, 484: cleanup: mcpCleanup, 485: } = await initializeAgentMcpServers( 486: agentDefinition, 487: toolUseContext.options.mcpClients, 488: ) 489: const allTools = 490: agentMcpTools.length > 0 491: ? uniqBy([...resolvedTools, ...agentMcpTools], 'name') 492: : resolvedTools 493: const agentOptions: ToolUseContext['options'] = { 494: isNonInteractiveSession: useExactTools 495: ? toolUseContext.options.isNonInteractiveSession 496: : isAsync 497: ? true 498: : (toolUseContext.options.isNonInteractiveSession ?? false), 499: appendSystemPrompt: toolUseContext.options.appendSystemPrompt, 500: tools: allTools, 501: commands: [], 502: debug: toolUseContext.options.debug, 503: verbose: toolUseContext.options.verbose, 504: mainLoopModel: resolvedAgentModel, 505: thinkingConfig: useExactTools 506: ? toolUseContext.options.thinkingConfig 507: : { type: 'disabled' as const }, 508: mcpClients: mergedMcpClients, 509: mcpResources: toolUseContext.options.mcpResources, 510: agentDefinitions: toolUseContext.options.agentDefinitions, 511: ...(useExactTools && { querySource }), 512: } 513: const agentToolUseContext = createSubagentContext(toolUseContext, { 514: options: agentOptions, 515: agentId, 516: agentType: agentDefinition.agentType, 517: messages: initialMessages, 518: readFileState: agentReadFileState, 519: abortController: agentAbortController, 520: getAppState: agentGetAppState, 521: shareSetAppState: !isAsync, 522: shareSetResponseLength: true, 523: criticalSystemReminder_EXPERIMENTAL: 524: agentDefinition.criticalSystemReminder_EXPERIMENTAL, 525: contentReplacementState, 526: }) 527: if (preserveToolUseResults) { 528: agentToolUseContext.preserveToolUseResults = true 529: } 530: if (onCacheSafeParams) { 531: onCacheSafeParams({ 532: systemPrompt: agentSystemPrompt, 533: userContext: resolvedUserContext, 534: systemContext: resolvedSystemContext, 535: toolUseContext: agentToolUseContext, 536: forkContextMessages: initialMessages, 537: }) 538: } 539: void recordSidechainTranscript(initialMessages, agentId).catch(_err => 540: logForDebugging(`Failed to record sidechain transcript: ${_err}`), 541: ) 542: void writeAgentMetadata(agentId, { 543: agentType: agentDefinition.agentType, 544: ...(worktreePath && { worktreePath }), 545: ...(description && { description }), 546: }).catch(_err => logForDebugging(`Failed to write agent metadata: ${_err}`)) 547: let lastRecordedUuid: UUID | null = initialMessages.at(-1)?.uuid ?? null 548: try { 549: for await (const message of query({ 550: messages: initialMessages, 551: systemPrompt: agentSystemPrompt, 552: userContext: resolvedUserContext, 553: systemContext: resolvedSystemContext, 554: canUseTool, 555: toolUseContext: agentToolUseContext, 556: querySource, 557: maxTurns: maxTurns ?? agentDefinition.maxTurns, 558: })) { 559: onQueryProgress?.() 560: if ( 561: message.type === 'stream_event' && 562: message.event.type === 'message_start' && 563: message.ttftMs != null 564: ) { 565: toolUseContext.pushApiMetricsEntry?.(message.ttftMs) 566: continue 567: } 568: if (message.type === 'attachment') { 569: if (message.attachment.type === 'max_turns_reached') { 570: logForDebugging( 571: `[Agent 572: : $ 573: { 574: agentDefinition.agentType 575: } 576: ] Reached max turns limit ($ 577: { 578: message.attachment.maxTurns 579: } 580: )`, 581: ) 582: break 583: } 584: yield message 585: continue 586: } 587: if (isRecordableMessage(message)) { 588: await recordSidechainTranscript( 589: [message], 590: agentId, 591: lastRecordedUuid, 592: ).catch(err => 593: logForDebugging(`Failed to record sidechain transcript: ${err}`), 594: ) 595: if (message.type !== 'progress') { 596: lastRecordedUuid = message.uuid 597: } 598: yield message 599: } 600: } 601: if (agentAbortController.signal.aborted) { 602: throw new AbortError() 603: } 604: if (isBuiltInAgent(agentDefinition) && agentDefinition.callback) { 605: agentDefinition.callback() 606: } 607: } finally { 608: await mcpCleanup() 609: if (agentDefinition.hooks) { 610: clearSessionHooks(rootSetAppState, agentId) 611: } 612: if (feature('PROMPT_CACHE_BREAK_DETECTION')) { 613: cleanupAgentTracking(agentId) 614: } 615: agentToolUseContext.readFileState.clear() 616: initialMessages.length = 0 617: unregisterPerfettoAgent(agentId) 618: clearAgentTranscriptSubdir(agentId) 619: rootSetAppState(prev => { 620: if (!(agentId in prev.todos)) return prev 621: const { [agentId]: _removed, ...todos } = prev.todos 622: return { ...prev, todos } 623: }) 624: killShellTasksForAgent(agentId, toolUseContext.getAppState, rootSetAppState) 625: if (feature('MONITOR_TOOL')) { 626: const mcpMod = 627: require('../../tasks/MonitorMcpTask/MonitorMcpTask.js') as typeof import('../../tasks/MonitorMcpTask/MonitorMcpTask.js') 628: mcpMod.killMonitorMcpTasksForAgent( 629: agentId, 630: toolUseContext.getAppState, 631: rootSetAppState, 632: ) 633: } 634: } 635: } 636: export function filterIncompleteToolCalls(messages: Message[]): Message[] { 637: const toolUseIdsWithResults = new Set<string>() 638: for (const message of messages) { 639: if (message?.type === 'user') { 640: const userMessage = message as UserMessage 641: const content = userMessage.message.content 642: if (Array.isArray(content)) { 643: for (const block of content) { 644: if (block.type === 'tool_result' && block.tool_use_id) { 645: toolUseIdsWithResults.add(block.tool_use_id) 646: } 647: } 648: } 649: } 650: } 651: return messages.filter(message => { 652: if (message?.type === 'assistant') { 653: const assistantMessage = message as AssistantMessage 654: const content = assistantMessage.message.content 655: if (Array.isArray(content)) { 656: const hasIncompleteToolCall = content.some( 657: block => 658: block.type === 'tool_use' && 659: block.id && 660: !toolUseIdsWithResults.has(block.id), 661: ) 662: return !hasIncompleteToolCall 663: } 664: } 665: return true 666: }) 667: } 668: async function getAgentSystemPrompt( 669: agentDefinition: AgentDefinition, 670: toolUseContext: Pick<ToolUseContext, 'options'>, 671: resolvedAgentModel: string, 672: additionalWorkingDirectories: string[], 673: resolvedTools: readonly Tool[], 674: ): Promise<string[]> { 675: const enabledToolNames = new Set(resolvedTools.map(t => t.name)) 676: try { 677: const agentPrompt = agentDefinition.getSystemPrompt({ toolUseContext }) 678: const prompts = [agentPrompt] 679: return await enhanceSystemPromptWithEnvDetails( 680: prompts, 681: resolvedAgentModel, 682: additionalWorkingDirectories, 683: enabledToolNames, 684: ) 685: } catch (_error) { 686: return enhanceSystemPromptWithEnvDetails( 687: [DEFAULT_AGENT_PROMPT], 688: resolvedAgentModel, 689: additionalWorkingDirectories, 690: enabledToolNames, 691: ) 692: } 693: } 694: function resolveSkillName( 695: skillName: string, 696: allSkills: Command[], 697: agentDefinition: AgentDefinition, 698: ): string | null { 699: if (hasCommand(skillName, allSkills)) { 700: return skillName 701: } 702: const pluginPrefix = agentDefinition.agentType.split(':')[0] 703: if (pluginPrefix) { 704: const qualifiedName = `${pluginPrefix}:${skillName}` 705: if (hasCommand(qualifiedName, allSkills)) { 706: return qualifiedName 707: } 708: } 709: const suffix = `:${skillName}` 710: const match = allSkills.find(cmd => cmd.name.endsWith(suffix)) 711: if (match) { 712: return match.name 713: } 714: return null 715: }

File: src/tools/AgentTool/UI.tsx

typescript 1: import { c as _c } from "react/compiler-runtime"; 2: import type { ToolResultBlockParam, ToolUseBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'; 3: import * as React from 'react'; 4: import { ConfigurableShortcutHint } from 'src/components/ConfigurableShortcutHint.js'; 5: import { CtrlOToExpand, SubAgentProvider } from 'src/components/CtrlOToExpand.js'; 6: import { Byline } from 'src/components/design-system/Byline.js'; 7: import { KeyboardShortcutHint } from 'src/components/design-system/KeyboardShortcutHint.js'; 8: import type { z } from 'zod/v4'; 9: import { AgentProgressLine } from '../../components/AgentProgressLine.js'; 10: import { FallbackToolUseErrorMessage } from '../../components/FallbackToolUseErrorMessage.js'; 11: import { FallbackToolUseRejectedMessage } from '../../components/FallbackToolUseRejectedMessage.js'; 12: import { Markdown } from '../../components/Markdown.js'; 13: import { Message as MessageComponent } from '../../components/Message.js'; 14: import { MessageResponse } from '../../components/MessageResponse.js'; 15: import { ToolUseLoader } from '../../components/ToolUseLoader.js'; 16: import { Box, Text } from '../../ink.js'; 17: import { getDumpPromptsPath } from '../../services/api/dumpPrompts.js'; 18: import { findToolByName, type Tools } from '../../Tool.js'; 19: import type { Message, ProgressMessage } from '../../types/message.js'; 20: import type { AgentToolProgress } from '../../types/tools.js'; 21: import { count } from '../../utils/array.js'; 22: import { getSearchOrReadFromContent, getSearchReadSummaryText } from '../../utils/collapseReadSearch.js'; 23: import { getDisplayPath } from '../../utils/file.js'; 24: import { formatDuration, formatNumber } from '../../utils/format.js'; 25: import { buildSubagentLookups, createAssistantMessage, EMPTY_LOOKUPS } from '../../utils/messages.js'; 26: import type { ModelAlias } from '../../utils/model/aliases.js'; 27: import { getMainLoopModel, parseUserSpecifiedModel, renderModelName } from '../../utils/model/model.js'; 28: import type { Theme, ThemeName } from '../../utils/theme.js'; 29: import type { outputSchema, Progress, RemoteLaunchedOutput } from './AgentTool.js'; 30: import { inputSchema } from './AgentTool.js'; 31: import { getAgentColor } from './agentColorManager.js'; 32: import { GENERAL_PURPOSE_AGENT } from './built-in/generalPurposeAgent.js'; 33: const MAX_PROGRESS_MESSAGES_TO_SHOW = 3; 34: function hasProgressMessage(data: Progress): data is AgentToolProgress { 35: if (!('message' in data)) { 36: return false; 37: } 38: const msg = (data as AgentToolProgress).message; 39: return msg != null && typeof msg === 'object' && 'type' in msg; 40: } 41: function getSearchOrReadInfo(progressMessage: ProgressMessage<Progress>, tools: Tools, toolUseByID: Map<string, ToolUseBlockParam>): { 42: isSearch: boolean; 43: isRead: boolean; 44: isREPL: boolean; 45: } | null { 46: if (!hasProgressMessage(progressMessage.data)) { 47: return null; 48: } 49: const message = progressMessage.data.message; 50: if (message.type === 'assistant') { 51: return getSearchOrReadFromContent(message.message.content[0], tools); 52: } 53: if (message.type === 'user') { 54: const content = message.message.content[0]; 55: if (content?.type === 'tool_result') { 56: const toolUse = toolUseByID.get(content.tool_use_id); 57: if (toolUse) { 58: return getSearchOrReadFromContent(toolUse, tools); 59: } 60: } 61: } 62: return null; 63: } 64: type SummaryMessage = { 65: type: 'summary'; 66: searchCount: number; 67: readCount: number; 68: replCount: number; 69: uuid: string; 70: isActive: boolean; 71: }; 72: type ProcessedMessage = { 73: type: 'original'; 74: message: ProgressMessage<AgentToolProgress>; 75: } | SummaryMessage; 76: function processProgressMessages(messages: ProgressMessage<Progress>[], tools: Tools, isAgentRunning: boolean): ProcessedMessage[] { 77: if ("external" !== 'ant') { 78: return messages.filter((m): m is ProgressMessage<AgentToolProgress> => hasProgressMessage(m.data) && m.data.message.type !== 'user').map(m => ({ 79: type: 'original', 80: message: m 81: })); 82: } 83: const result: ProcessedMessage[] = []; 84: let currentGroup: { 85: searchCount: number; 86: readCount: number; 87: replCount: number; 88: startUuid: string; 89: } | null = null; 90: function flushGroup(isActive: boolean): void { 91: if (currentGroup && (currentGroup.searchCount > 0 || currentGroup.readCount > 0 || currentGroup.replCount > 0)) { 92: result.push({ 93: type: 'summary', 94: searchCount: currentGroup.searchCount, 95: readCount: currentGroup.readCount, 96: replCount: currentGroup.replCount, 97: uuid: `summary-${currentGroup.startUuid}`, 98: isActive 99: }); 100: } 101: currentGroup = null; 102: } 103: const agentMessages = messages.filter((m): m is ProgressMessage<AgentToolProgress> => hasProgressMessage(m.data)); 104: const toolUseByID = new Map<string, ToolUseBlockParam>(); 105: for (const msg of agentMessages) { 106: if (msg.data.message.type === 'assistant') { 107: for (const c of msg.data.message.message.content) { 108: if (c.type === 'tool_use') { 109: toolUseByID.set(c.id, c as ToolUseBlockParam); 110: } 111: } 112: } 113: const info = getSearchOrReadInfo(msg, tools, toolUseByID); 114: if (info && (info.isSearch || info.isRead || info.isREPL)) { 115: if (!currentGroup) { 116: currentGroup = { 117: searchCount: 0, 118: readCount: 0, 119: replCount: 0, 120: startUuid: msg.uuid 121: }; 122: } 123: if (msg.data.message.type === 'user') { 124: if (info.isSearch) { 125: currentGroup.searchCount++; 126: } else if (info.isREPL) { 127: currentGroup.replCount++; 128: } else if (info.isRead) { 129: currentGroup.readCount++; 130: } 131: } 132: } else { 133: flushGroup(false); 134: if (msg.data.message.type !== 'user') { 135: result.push({ 136: type: 'original', 137: message: msg 138: }); 139: } 140: } 141: } 142: flushGroup(isAgentRunning); 143: return result; 144: } 145: const ESTIMATED_LINES_PER_TOOL = 9; 146: const TERMINAL_BUFFER_LINES = 7; 147: type Output = z.input<ReturnType<typeof outputSchema>>; 148: export function AgentPromptDisplay(t0) { 149: const $ = _c(3); 150: const { 151: prompt, 152: dim: t1 153: } = t0; 154: t1 === undefined ? false : t1; 155: let t2; 156: if ($[0] === Symbol.for("react.memo_cache_sentinel")) { 157: t2 = <Text color="success" bold={true}>Prompt:</Text>; 158: $[0] = t2; 159: } else { 160: t2 = $[0]; 161: } 162: let t3; 163: if ($[1] !== prompt) { 164: t3 = <Box flexDirection="column">{t2}<Box paddingLeft={2}><Markdown>{prompt}</Markdown></Box></Box>; 165: $[1] = prompt; 166: $[2] = t3; 167: } else { 168: t3 = $[2]; 169: } 170: return t3; 171: } 172: export function AgentResponseDisplay(t0) { 173: const $ = _c(5); 174: const { 175: content 176: } = t0; 177: let t1; 178: if ($[0] === Symbol.for("react.memo_cache_sentinel")) { 179: t1 = <Text color="success" bold={true}>Response:</Text>; 180: $[0] = t1; 181: } else { 182: t1 = $[0]; 183: } 184: let t2; 185: if ($[1] !== content) { 186: t2 = content.map(_temp); 187: $[1] = content; 188: $[2] = t2; 189: } else { 190: t2 = $[2]; 191: } 192: let t3; 193: if ($[3] !== t2) { 194: t3 = <Box flexDirection="column">{t1}{t2}</Box>; 195: $[3] = t2; 196: $[4] = t3; 197: } else { 198: t3 = $[4]; 199: } 200: return t3; 201: } 202: function _temp(block, index) { 203: return <Box key={index} paddingLeft={2} marginTop={index === 0 ? 0 : 1}><Markdown>{block.text}</Markdown></Box>; 204: } 205: type VerboseAgentTranscriptProps = { 206: progressMessages: ProgressMessage<Progress>[]; 207: tools: Tools; 208: verbose: boolean; 209: }; 210: function VerboseAgentTranscript(t0) { 211: const $ = _c(15); 212: const { 213: progressMessages, 214: tools, 215: verbose 216: } = t0; 217: let t1; 218: if ($[0] !== progressMessages) { 219: t1 = buildSubagentLookups(progressMessages.filter(_temp2).map(_temp3)); 220: $[0] = progressMessages; 221: $[1] = t1; 222: } else { 223: t1 = $[1]; 224: } 225: const { 226: lookups: agentLookups, 227: inProgressToolUseIDs 228: } = t1; 229: let t2; 230: if ($[2] !== agentLookups || $[3] !== inProgressToolUseIDs || $[4] !== progressMessages || $[5] !== tools || $[6] !== verbose) { 231: const filteredMessages = progressMessages.filter(_temp4); 232: let t3; 233: if ($[8] !== agentLookups || $[9] !== inProgressToolUseIDs || $[10] !== tools || $[11] !== verbose) { 234: t3 = progressMessage => <MessageResponse key={progressMessage.uuid} height={1}><MessageComponent message={progressMessage.data.message} lookups={agentLookups} addMargin={false} tools={tools} commands={[]} verbose={verbose} inProgressToolUseIDs={inProgressToolUseIDs} progressMessagesForMessage={[]} shouldAnimate={false} shouldShowDot={false} isTranscriptMode={false} isStatic={true} /></MessageResponse>; 235: $[8] = agentLookups; 236: $[9] = inProgressToolUseIDs; 237: $[10] = tools; 238: $[11] = verbose; 239: $[12] = t3; 240: } else { 241: t3 = $[12]; 242: } 243: t2 = filteredMessages.map(t3); 244: $[2] = agentLookups; 245: $[3] = inProgressToolUseIDs; 246: $[4] = progressMessages; 247: $[5] = tools; 248: $[6] = verbose; 249: $[7] = t2; 250: } else { 251: t2 = $[7]; 252: } 253: let t3; 254: if ($[13] !== t2) { 255: t3 = <>{t2}</>; 256: $[13] = t2; 257: $[14] = t3; 258: } else { 259: t3 = $[14]; 260: } 261: return t3; 262: } 263: function _temp4(pm_1) { 264: if (!hasProgressMessage(pm_1.data)) { 265: return false; 266: } 267: const msg = pm_1.data.message; 268: if (msg.type === "user" && msg.toolUseResult === undefined) { 269: return false; 270: } 271: return true; 272: } 273: function _temp3(pm_0) { 274: return pm_0.data; 275: } 276: function _temp2(pm) { 277: return hasProgressMessage(pm.data); 278: } 279: export function renderToolResultMessage(data: Output, progressMessagesForMessage: ProgressMessage<Progress>[], { 280: tools, 281: verbose, 282: theme, 283: isTranscriptMode = false 284: }: { 285: tools: Tools; 286: verbose: boolean; 287: theme: ThemeName; 288: isTranscriptMode?: boolean; 289: }): React.ReactNode { 290: const internal = data as Output | RemoteLaunchedOutput; 291: if (internal.status === 'remote_launched') { 292: return <Box flexDirection="column"> 293: <MessageResponse height={1}> 294: <Text> 295: Remote agent launched{' '} 296: <Text dimColor> 297: · {internal.taskId} · {internal.sessionUrl} 298: </Text> 299: </Text> 300: </MessageResponse> 301: </Box>; 302: } 303: if (data.status === 'async_launched') { 304: const { 305: prompt 306: } = data; 307: return <Box flexDirection="column"> 308: <MessageResponse height={1}> 309: <Text> 310: Backgrounded agent 311: {!isTranscriptMode && <Text dimColor> 312: {' ('} 313: <Byline> 314: <KeyboardShortcutHint shortcut="↓" action="manage" /> 315: {prompt && <ConfigurableShortcutHint action="app:toggleTranscript" context="Global" fallback="ctrl+o" description="expand" />} 316: </Byline> 317: {')'} 318: </Text>} 319: </Text> 320: </MessageResponse> 321: {isTranscriptMode && prompt && <MessageResponse> 322: <AgentPromptDisplay prompt={prompt} theme={theme} /> 323: </MessageResponse>} 324: </Box>; 325: } 326: if (data.status !== 'completed') { 327: return null; 328: } 329: const { 330: agentId, 331: totalDurationMs, 332: totalToolUseCount, 333: totalTokens, 334: usage, 335: content, 336: prompt 337: } = data; 338: const result = [totalToolUseCount === 1 ? '1 tool use' : `${totalToolUseCount} tool uses`, formatNumber(totalTokens) + ' tokens', formatDuration(totalDurationMs)]; 339: const completionMessage = `Done (${result.join(' · ')})`; 340: const finalAssistantMessage = createAssistantMessage({ 341: content: completionMessage, 342: usage: { 343: ...usage, 344: inference_geo: null, 345: iterations: null, 346: speed: null 347: } 348: }); 349: return <Box flexDirection="column"> 350: {"external" === 'ant' && <MessageResponse> 351: <Text color="warning"> 352: [ANT-ONLY] API calls: {getDisplayPath(getDumpPromptsPath(agentId))} 353: </Text> 354: </MessageResponse>} 355: {isTranscriptMode && prompt && <MessageResponse> 356: <AgentPromptDisplay prompt={prompt} theme={theme} /> 357: </MessageResponse>} 358: {isTranscriptMode ? <SubAgentProvider> 359: <VerboseAgentTranscript progressMessages={progressMessagesForMessage} tools={tools} verbose={verbose} /> 360: </SubAgentProvider> : null} 361: {isTranscriptMode && content && content.length > 0 && <MessageResponse> 362: <AgentResponseDisplay content={content} theme={theme} /> 363: </MessageResponse>} 364: <MessageResponse height={1}> 365: <MessageComponent message={finalAssistantMessage} lookups={EMPTY_LOOKUPS} addMargin={false} tools={tools} commands={[]} verbose={verbose} inProgressToolUseIDs={new Set()} progressMessagesForMessage={[]} shouldAnimate={false} shouldShowDot={false} isTranscriptMode={false} isStatic={true} /> 366: </MessageResponse> 367: {!isTranscriptMode && <Text dimColor> 368: {' '} 369: <CtrlOToExpand /> 370: </Text>} 371: </Box>; 372: } 373: export function renderToolUseMessage({ 374: description, 375: prompt 376: }: Partial<{ 377: description: string; 378: prompt: string; 379: }>): React.ReactNode { 380: if (!description || !prompt) { 381: return null; 382: } 383: return description; 384: } 385: export function renderToolUseTag(input: Partial<{ 386: description: string; 387: prompt: string; 388: subagent_type: string; 389: model?: ModelAlias; 390: }>): React.ReactNode { 391: const tags: React.ReactNode[] = []; 392: if (input.model) { 393: const mainModel = getMainLoopModel(); 394: const agentModel = parseUserSpecifiedModel(input.model); 395: if (agentModel !== mainModel) { 396: tags.push(<Box key="model" flexWrap="nowrap" marginLeft={1}> 397: <Text dimColor>{renderModelName(agentModel)}</Text> 398: </Box>); 399: } 400: } 401: if (tags.length === 0) { 402: return null; 403: } 404: return <>{tags}</>; 405: } 406: const INITIALIZING_TEXT = 'Initializing…'; 407: export function renderToolUseProgressMessage(progressMessages: ProgressMessage<Progress>[], { 408: tools, 409: verbose, 410: terminalSize, 411: inProgressToolCallCount, 412: isTranscriptMode = false 413: }: { 414: tools: Tools; 415: verbose: boolean; 416: terminalSize?: { 417: columns: number; 418: rows: number; 419: }; 420: inProgressToolCallCount?: number; 421: isTranscriptMode?: boolean; 422: }): React.ReactNode { 423: if (!progressMessages.length) { 424: return <MessageResponse height={1}> 425: <Text dimColor>{INITIALIZING_TEXT}</Text> 426: </MessageResponse>; 427: } 428: const toolToolRenderLinesEstimate = (inProgressToolCallCount ?? 1) * ESTIMATED_LINES_PER_TOOL + TERMINAL_BUFFER_LINES; 429: const shouldUseCondensedMode = !isTranscriptMode && terminalSize && terminalSize.rows && terminalSize.rows < toolToolRenderLinesEstimate; 430: const getProgressStats = () => { 431: const toolUseCount = count(progressMessages, msg => { 432: if (!hasProgressMessage(msg.data)) { 433: return false; 434: } 435: const message = msg.data.message; 436: return message.message.content.some(content => content.type === 'tool_use'); 437: }); 438: const latestAssistant = progressMessages.findLast((msg): msg is ProgressMessage<AgentToolProgress> => hasProgressMessage(msg.data) && msg.data.message.type === 'assistant'); 439: let tokens = null; 440: if (latestAssistant?.data.message.type === 'assistant') { 441: const usage = latestAssistant.data.message.message.usage; 442: tokens = (usage.cache_creation_input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0) + usage.input_tokens + usage.output_tokens; 443: } 444: return { 445: toolUseCount, 446: tokens 447: }; 448: }; 449: if (shouldUseCondensedMode) { 450: const { 451: toolUseCount, 452: tokens 453: } = getProgressStats(); 454: return <MessageResponse height={1}> 455: <Text dimColor> 456: In progress… · <Text bold>{toolUseCount}</Text> tool{' '} 457: {toolUseCount === 1 ? 'use' : 'uses'} 458: {tokens && ` · ${formatNumber(tokens)} tokens`} ·{' '} 459: <ConfigurableShortcutHint action="app:toggleTranscript" context="Global" fallback="ctrl+o" description="expand" parens /> 460: </Text> 461: </MessageResponse>; 462: } 463: const processedMessages = processProgressMessages(progressMessages, tools, true); 464: const displayedMessages = isTranscriptMode ? processedMessages : processedMessages.slice(-MAX_PROGRESS_MESSAGES_TO_SHOW); 465: const hiddenMessages = isTranscriptMode ? [] : processedMessages.slice(0, Math.max(0, processedMessages.length - MAX_PROGRESS_MESSAGES_TO_SHOW)); 466: const hiddenToolUseCount = count(hiddenMessages, m => { 467: if (m.type === 'summary') { 468: return m.searchCount + m.readCount + m.replCount > 0; 469: } 470: const data = m.message.data; 471: if (!hasProgressMessage(data)) { 472: return false; 473: } 474: return data.message.message.content.some(content => content.type === 'tool_use'); 475: }); 476: const firstData = progressMessages[0]?.data; 477: const prompt = firstData && hasProgressMessage(firstData) ? firstData.prompt : undefined; 478: if (displayedMessages.length === 0 && !(isTranscriptMode && prompt)) { 479: return <MessageResponse height={1}> 480: <Text dimColor>{INITIALIZING_TEXT}</Text> 481: </MessageResponse>; 482: } 483: const { 484: lookups: subagentLookups, 485: inProgressToolUseIDs: collapsedInProgressIDs 486: } = buildSubagentLookups(progressMessages.filter((pm): pm is ProgressMessage<AgentToolProgress> => hasProgressMessage(pm.data)).map(pm => pm.data)); 487: return <MessageResponse> 488: <Box flexDirection="column"> 489: <SubAgentProvider> 490: {isTranscriptMode && prompt && <Box marginBottom={1}> 491: <AgentPromptDisplay prompt={prompt} /> 492: </Box>} 493: {displayedMessages.map(processed => { 494: if (processed.type === 'summary') { 495: const summaryText = getSearchReadSummaryText(processed.searchCount, processed.readCount, processed.isActive, processed.replCount); 496: return <Box key={processed.uuid} height={1} overflow="hidden"> 497: <Text dimColor>{summaryText}</Text> 498: </Box>; 499: } 500: return <MessageComponent key={processed.message.uuid} message={processed.message.data.message} lookups={subagentLookups} addMargin={false} tools={tools} commands={[]} verbose={verbose} inProgressToolUseIDs={collapsedInProgressIDs} progressMessagesForMessage={[]} shouldAnimate={false} shouldShowDot={false} style="condensed" isTranscriptMode={false} isStatic={true} />; 501: })} 502: </SubAgentProvider> 503: {hiddenToolUseCount > 0 && <Text dimColor> 504: +{hiddenToolUseCount} more tool{' '} 505: {hiddenToolUseCount === 1 ? 'use' : 'uses'} <CtrlOToExpand /> 506: </Text>} 507: </Box> 508: </MessageResponse>; 509: } 510: export function renderToolUseRejectedMessage(_input: { 511: description: string; 512: prompt: string; 513: subagent_type: string; 514: }, { 515: progressMessagesForMessage, 516: tools, 517: verbose, 518: isTranscriptMode 519: }: { 520: columns: number; 521: messages: Message[]; 522: style?: 'condensed'; 523: theme: ThemeName; 524: progressMessagesForMessage: ProgressMessage<Progress>[]; 525: tools: Tools; 526: verbose: boolean; 527: isTranscriptMode?: boolean; 528: }): React.ReactNode { 529: const firstData = progressMessagesForMessage[0]?.data; 530: const agentId = firstData && hasProgressMessage(firstData) ? firstData.agentId : undefined; 531: return <> 532: {"external" === 'ant' && agentId && <MessageResponse> 533: <Text color="warning"> 534: [ANT-ONLY] API calls: {getDisplayPath(getDumpPromptsPath(agentId))} 535: </Text> 536: </MessageResponse>} 537: {renderToolUseProgressMessage(progressMessagesForMessage, { 538: tools, 539: verbose, 540: isTranscriptMode 541: })} 542: <FallbackToolUseRejectedMessage /> 543: </>; 544: } 545: export function renderToolUseErrorMessage(result: ToolResultBlockParam['content'], { 546: progressMessagesForMessage, 547: tools, 548: verbose, 549: isTranscriptMode 550: }: { 551: progressMessagesForMessage: ProgressMessage<Progress>[]; 552: tools: Tools; 553: verbose: boolean; 554: isTranscriptMode?: boolean; 555: }): React.ReactNode { 556: return <> 557: {renderToolUseProgressMessage(progressMessagesForMessage, { 558: tools, 559: verbose, 560: isTranscriptMode 561: })} 562: <FallbackToolUseErrorMessage result={result} verbose={verbose} /> 563: </>; 564: } 565: function calculateAgentStats(progressMessages: ProgressMessage<Progress>[]): { 566: toolUseCount: number; 567: tokens: number | null; 568: } { 569: const toolUseCount = count(progressMessages, msg => { 570: if (!hasProgressMessage(msg.data)) { 571: return false; 572: } 573: const message = msg.data.message; 574: return message.type === 'user' && message.message.content.some(content => content.type === 'tool_result'); 575: }); 576: const latestAssistant = progressMessages.findLast((msg): msg is ProgressMessage<AgentToolProgress> => hasProgressMessage(msg.data) && msg.data.message.type === 'assistant'); 577: let tokens = null; 578: if (latestAssistant?.data.message.type === 'assistant') { 579: const usage = latestAssistant.data.message.message.usage; 580: tokens = (usage.cache_creation_input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0) + usage.input_tokens + usage.output_tokens; 581: } 582: return { 583: toolUseCount, 584: tokens 585: }; 586: } 587: export function renderGroupedAgentToolUse(toolUses: Array<{ 588: param: ToolUseBlockParam; 589: isResolved: boolean; 590: isError: boolean; 591: isInProgress: boolean; 592: progressMessages: ProgressMessage<Progress>[]; 593: result?: { 594: param: ToolResultBlockParam; 595: output: Output; 596: }; 597: }>, options: { 598: shouldAnimate: boolean; 599: tools: Tools; 600: }): React.ReactNode | null { 601: const { 602: shouldAnimate, 603: tools 604: } = options; 605: const agentStats = toolUses.map(({ 606: param, 607: isResolved, 608: isError, 609: progressMessages, 610: result 611: }) => { 612: const stats = calculateAgentStats(progressMessages); 613: const lastToolInfo = extractLastToolInfo(progressMessages, tools); 614: const parsedInput = inputSchema().safeParse(param.input); 615: const isTeammateSpawn = result?.output?.status as string === 'teammate_spawned'; 616: let agentType: string; 617: let description: string | undefined; 618: let color: keyof Theme | undefined; 619: let descriptionColor: keyof Theme | undefined; 620: let taskDescription: string | undefined; 621: if (isTeammateSpawn && parsedInput.success && parsedInput.data.name) { 622: agentType = `@${parsedInput.data.name}`; 623: const subagentType = parsedInput.data.subagent_type; 624: description = isCustomSubagentType(subagentType) ? subagentType : undefined; 625: taskDescription = parsedInput.data.description; 626: descriptionColor = isCustomSubagentType(subagentType) ? getAgentColor(subagentType) as keyof Theme | undefined : undefined; 627: } else { 628: agentType = parsedInput.success ? userFacingName(parsedInput.data) : 'Agent'; 629: description = parsedInput.success ? parsedInput.data.description : undefined; 630: color = parsedInput.success ? userFacingNameBackgroundColor(parsedInput.data) : undefined; 631: taskDescription = undefined; 632: } 633: const launchedAsAsync = parsedInput.success && 'run_in_background' in parsedInput.data && parsedInput.data.run_in_background === true; 634: const outputStatus = (result?.output as { 635: status?: string; 636: } | undefined)?.status; 637: const backgroundedMidExecution = outputStatus === 'async_launched' || outputStatus === 'remote_launched'; 638: const isAsync = launchedAsAsync || backgroundedMidExecution || isTeammateSpawn; 639: const name = parsedInput.success ? parsedInput.data.name : undefined; 640: return { 641: id: param.id, 642: agentType, 643: description, 644: toolUseCount: stats.toolUseCount, 645: tokens: stats.tokens, 646: isResolved, 647: isError, 648: isAsync, 649: color, 650: descriptionColor, 651: lastToolInfo, 652: taskDescription, 653: name 654: }; 655: }); 656: const anyUnresolved = toolUses.some(t => !t.isResolved); 657: const anyError = toolUses.some(t => t.isError); 658: const allComplete = !anyUnresolved; 659: const allSameType = agentStats.length > 0 && agentStats.every(stat => stat.agentType === agentStats[0]?.agentType); 660: const commonType = allSameType && agentStats[0]?.agentType !== 'Agent' ? agentStats[0]?.agentType : null; 661: const allAsync = agentStats.every(stat => stat.isAsync); 662: return <Box flexDirection="column" marginTop={1}> 663: <Box flexDirection="row"> 664: <ToolUseLoader shouldAnimate={shouldAnimate && anyUnresolved} isUnresolved={anyUnresolved} isError={anyError} /> 665: <Text> 666: {allComplete ? allAsync ? <> 667: <Text bold>{toolUses.length}</Text> background agents launched{' '} 668: <Text dimColor> 669: <KeyboardShortcutHint shortcut="↓" action="manage" parens /> 670: </Text> 671: </> : <> 672: <Text bold>{toolUses.length}</Text>{' '} 673: {commonType ? `${commonType} agents` : 'agents'} finished 674: </> : <> 675: Running <Text bold>{toolUses.length}</Text>{' '} 676: {commonType ? `${commonType} agents` : 'agents'}… 677: </>}{' '} 678: </Text> 679: {!allAsync && <CtrlOToExpand />} 680: </Box> 681: {agentStats.map((stat, index) => <AgentProgressLine key={stat.id} agentType={stat.agentType} description={stat.description} descriptionColor={stat.descriptionColor} taskDescription={stat.taskDescription} toolUseCount={stat.toolUseCount} tokens={stat.tokens} color={stat.color} isLast={index === agentStats.length - 1} isResolved={stat.isResolved} isError={stat.isError} isAsync={stat.isAsync} shouldAnimate={shouldAnimate} lastToolInfo={stat.lastToolInfo} hideType={allSameType} name={stat.name} />)} 682: </Box>; 683: } 684: export function userFacingName(input: Partial<{ 685: description: string; 686: prompt: string; 687: subagent_type: string; 688: name: string; 689: team_name: string; 690: }> | undefined): string { 691: if (input?.subagent_type && input.subagent_type !== GENERAL_PURPOSE_AGENT.agentType) { 692: if (input.subagent_type === 'worker') { 693: return 'Agent'; 694: } 695: return input.subagent_type; 696: } 697: return 'Agent'; 698: } 699: export function userFacingNameBackgroundColor(input: Partial<{ 700: description: string; 701: prompt: string; 702: subagent_type: string; 703: }> | undefined): keyof Theme | undefined { 704: if (!input?.subagent_type) { 705: return undefined; 706: } 707: return getAgentColor(input.subagent_type) as keyof Theme | undefined; 708: } 709: export function extractLastToolInfo(progressMessages: ProgressMessage<Progress>[], tools: Tools): string | null { 710: const toolUseByID = new Map<string, ToolUseBlockParam>(); 711: for (const pm of progressMessages) { 712: if (!hasProgressMessage(pm.data)) { 713: continue; 714: } 715: if (pm.data.message.type === 'assistant') { 716: for (const c of pm.data.message.message.content) { 717: if (c.type === 'tool_use') { 718: toolUseByID.set(c.id, c as ToolUseBlockParam); 719: } 720: } 721: } 722: } 723: let searchCount = 0; 724: let readCount = 0; 725: for (let i = progressMessages.length - 1; i >= 0; i--) { 726: const msg = progressMessages[i]!; 727: if (!hasProgressMessage(msg.data)) { 728: continue; 729: } 730: const info = getSearchOrReadInfo(msg, tools, toolUseByID); 731: if (info && (info.isSearch || info.isRead)) { 732: if (msg.data.message.type === 'user') { 733: if (info.isSearch) { 734: searchCount++; 735: } else if (info.isRead) { 736: readCount++; 737: } 738: } 739: } else { 740: break; 741: } 742: } 743: if (searchCount + readCount >= 2) { 744: return getSearchReadSummaryText(searchCount, readCount, true); 745: } 746: const lastToolResult = progressMessages.findLast((msg): msg is ProgressMessage<AgentToolProgress> => { 747: if (!hasProgressMessage(msg.data)) { 748: return false; 749: } 750: const message = msg.data.message; 751: return message.type === 'user' && message.message.content.some(c => c.type === 'tool_result'); 752: }); 753: if (lastToolResult?.data.message.type === 'user') { 754: const toolResultBlock = lastToolResult.data.message.message.content.find(c => c.type === 'tool_result'); 755: if (toolResultBlock?.type === 'tool_result') { 756: const toolUseBlock = toolUseByID.get(toolResultBlock.tool_use_id); 757: if (toolUseBlock) { 758: const tool = findToolByName(tools, toolUseBlock.name); 759: if (!tool) { 760: return toolUseBlock.name; 761: } 762: const input = toolUseBlock.input as Record<string, unknown>; 763: const parsedInput = tool.inputSchema.safeParse(input); 764: const userFacingToolName = tool.userFacingName(parsedInput.success ? parsedInput.data : undefined); 765: if (tool.getToolUseSummary) { 766: const summary = tool.getToolUseSummary(parsedInput.success ? parsedInput.data : undefined); 767: if (summary) { 768: return `${userFacingToolName}: ${summary}`; 769: } 770: } 771: return userFacingToolName; 772: } 773: } 774: } 775: return null; 776: } 777: function isCustomSubagentType(subagentType: string | undefined): subagentType is string { 778: return !!subagentType && subagentType !== GENERAL_PURPOSE_AGENT.agentType && subagentType !== 'worker'; 779: }

File: src/tools/AskUserQuestionTool/AskUserQuestionTool.tsx

typescript 1: import { c as _c } from "react/compiler-runtime"; 2: import { feature } from 'bun:bundle'; 3: import * as React from 'react'; 4: import { getAllowedChannels, getQuestionPreviewFormat } from 'src/bootstrap/state.js'; 5: import { MessageResponse } from 'src/components/MessageResponse.js'; 6: import { BLACK_CIRCLE } from 'src/constants/figures.js'; 7: import { getModeColor } from 'src/utils/permissions/PermissionMode.js'; 8: import { z } from 'zod/v4'; 9: import { Box, Text } from '../../ink.js'; 10: import type { Tool } from '../../Tool.js'; 11: import { buildTool, type ToolDef } from '../../Tool.js'; 12: import { lazySchema } from '../../utils/lazySchema.js'; 13: import { ASK_USER_QUESTION_TOOL_CHIP_WIDTH, ASK_USER_QUESTION_TOOL_NAME, ASK_USER_QUESTION_TOOL_PROMPT, DESCRIPTION, PREVIEW_FEATURE_PROMPT } from './prompt.js'; 14: const questionOptionSchema = lazySchema(() => z.object({ 15: label: z.string().describe('The display text for this option that the user will see and select. Should be concise (1-5 words) and clearly describe the choice.'), 16: description: z.string().describe('Explanation of what this option means or what will happen if chosen. Useful for providing context about trade-offs or implications.'), 17: preview: z.string().optional().describe('Optional preview content rendered when this option is focused. Use for mockups, code snippets, or visual comparisons that help users compare options. See the tool description for the expected content format.') 18: })); 19: const questionSchema = lazySchema(() => z.object({ 20: question: z.string().describe('The complete question to ask the user. Should be clear, specific, and end with a question mark. Example: "Which library should we use for date formatting?" If multiSelect is true, phrase it accordingly, e.g. "Which features do you want to enable?"'), 21: header: z.string().describe(`Very short label displayed as a chip/tag (max ${ASK_USER_QUESTION_TOOL_CHIP_WIDTH} chars). Examples: "Auth method", "Library", "Approach".`), 22: options: z.array(questionOptionSchema()).min(2).max(4).describe(`The available choices for this question. Must have 2-4 options. Each option should be a distinct, mutually exclusive choice (unless multiSelect is enabled). There should be no 'Other' option, that will be provided automatically.`), 23: multiSelect: z.boolean().default(false).describe('Set to true to allow the user to select multiple options instead of just one. Use when choices are not mutually exclusive.') 24: })); 25: const annotationsSchema = lazySchema(() => { 26: const annotationSchema = z.object({ 27: preview: z.string().optional().describe('The preview content of the selected option, if the question used previews.'), 28: notes: z.string().optional().describe('Free-text notes the user added to their selection.') 29: }); 30: return z.record(z.string(), annotationSchema).optional().describe('Optional per-question annotations from the user (e.g., notes on preview selections). Keyed by question text.'); 31: }); 32: const UNIQUENESS_REFINE = { 33: check: (data: { 34: questions: { 35: question: string; 36: options: { 37: label: string; 38: }[]; 39: }[]; 40: }) => { 41: const questions = data.questions.map(q => q.question); 42: if (questions.length !== new Set(questions).size) { 43: return false; 44: } 45: for (const question of data.questions) { 46: const labels = question.options.map(opt => opt.label); 47: if (labels.length !== new Set(labels).size) { 48: return false; 49: } 50: } 51: return true; 52: }, 53: message: 'Question texts must be unique, option labels must be unique within each question' 54: } as const; 55: const commonFields = lazySchema(() => ({ 56: answers: z.record(z.string(), z.string()).optional().describe('User answers collected by the permission component'), 57: annotations: annotationsSchema(), 58: metadata: z.object({ 59: source: z.string().optional().describe('Optional identifier for the source of this question (e.g., "remember" for /remember command). Used for analytics tracking.') 60: }).optional().describe('Optional metadata for tracking and analytics purposes. Not displayed to user.') 61: })); 62: const inputSchema = lazySchema(() => z.strictObject({ 63: questions: z.array(questionSchema()).min(1).max(4).describe('Questions to ask the user (1-4 questions)'), 64: ...commonFields() 65: }).refine(UNIQUENESS_REFINE.check, { 66: message: UNIQUENESS_REFINE.message 67: })); 68: type InputSchema = ReturnType<typeof inputSchema>; 69: const outputSchema = lazySchema(() => z.object({ 70: questions: z.array(questionSchema()).describe('The questions that were asked'), 71: answers: z.record(z.string(), z.string()).describe('The answers provided by the user (question text -> answer string; multi-select answers are comma-separated)'), 72: annotations: annotationsSchema() 73: })); 74: type OutputSchema = ReturnType<typeof outputSchema>; 75: export const _sdkInputSchema = inputSchema; 76: export const _sdkOutputSchema = outputSchema; 77: export type Question = z.infer<ReturnType<typeof questionSchema>>; 78: export type QuestionOption = z.infer<ReturnType<typeof questionOptionSchema>>; 79: export type Output = z.infer<OutputSchema>; 80: function AskUserQuestionResultMessage(t0) { 81: const $ = _c(3); 82: const { 83: answers 84: } = t0; 85: let t1; 86: if ($[0] === Symbol.for("react.memo_cache_sentinel")) { 87: t1 = <Box flexDirection="row"><Text color={getModeColor("default")}>{BLACK_CIRCLE} </Text><Text>User answered Claude's questions:</Text></Box>; 88: $[0] = t1; 89: } else { 90: t1 = $[0]; 91: } 92: let t2; 93: if ($[1] !== answers) { 94: t2 = <Box flexDirection="column" marginTop={1}>{t1}<MessageResponse><Box flexDirection="column">{Object.entries(answers).map(_temp)}</Box></MessageResponse></Box>; 95: $[1] = answers; 96: $[2] = t2; 97: } else { 98: t2 = $[2]; 99: } 100: return t2; 101: } 102: function _temp(t0) { 103: const [questionText, answer] = t0; 104: return <Text key={questionText} color="inactive">· {questionText} → {answer}</Text>; 105: } 106: export const AskUserQuestionTool: Tool<InputSchema, Output> = buildTool({ 107: name: ASK_USER_QUESTION_TOOL_NAME, 108: searchHint: 'prompt the user with a multiple-choice question', 109: maxResultSizeChars: 100_000, 110: shouldDefer: true, 111: async description() { 112: return DESCRIPTION; 113: }, 114: async prompt() { 115: const format = getQuestionPreviewFormat(); 116: if (format === undefined) { 117: return ASK_USER_QUESTION_TOOL_PROMPT; 118: } 119: return ASK_USER_QUESTION_TOOL_PROMPT + PREVIEW_FEATURE_PROMPT[format]; 120: }, 121: get inputSchema(): InputSchema { 122: return inputSchema(); 123: }, 124: get outputSchema(): OutputSchema { 125: return outputSchema(); 126: }, 127: userFacingName() { 128: return ''; 129: }, 130: isEnabled() { 131: // When --channels is active the user is likely on Telegram/Discord, not 132: // watching the TUI. The multiple-choice dialog would hang with nobody at 133: // the keyboard. Channel permission relay already skips 134: // requiresUserInteraction() tools (interactiveHandler.ts) so there's 135: if ((feature('KAIROS') || feature('KAIROS_CHANNELS')) && getAllowedChannels().length > 0) { 136: return false; 137: } 138: return true; 139: }, 140: isConcurrencySafe() { 141: return true; 142: }, 143: isReadOnly() { 144: return true; 145: }, 146: toAutoClassifierInput(input) { 147: return input.questions.map(q => q.question).join(' | '); 148: }, 149: requiresUserInteraction() { 150: return true; 151: }, 152: async validateInput({ 153: questions 154: }) { 155: if (getQuestionPreviewFormat() !== 'html') { 156: return { 157: result: true 158: }; 159: } 160: for (const q of questions) { 161: for (const opt of q.options) { 162: const err = validateHtmlPreview(opt.preview); 163: if (err) { 164: return { 165: result: false, 166: message: `Option "${opt.label}" in question "${q.question}": ${err}`, 167: errorCode: 1 168: }; 169: } 170: } 171: } 172: return { 173: result: true 174: }; 175: }, 176: async checkPermissions(input) { 177: return { 178: behavior: 'ask' as const, 179: message: 'Answer questions?', 180: updatedInput: input 181: }; 182: }, 183: renderToolUseMessage() { 184: return null; 185: }, 186: renderToolUseProgressMessage() { 187: return null; 188: }, 189: renderToolResultMessage({ 190: answers 191: }, _toolUseID) { 192: return <AskUserQuestionResultMessage answers={answers} />; 193: }, 194: renderToolUseRejectedMessage() { 195: return <Box flexDirection="row" marginTop={1}> 196: <Text color={getModeColor('default')}>{BLACK_CIRCLE}&nbsp;</Text> 197: <Text>User declined to answer questions</Text> 198: </Box>; 199: }, 200: renderToolUseErrorMessage() { 201: return null; 202: }, 203: async call({ 204: questions, 205: answers = {}, 206: annotations 207: }, _context) { 208: return { 209: data: { 210: questions, 211: answers, 212: ...(annotations && { 213: annotations 214: }) 215: } 216: }; 217: }, 218: mapToolResultToToolResultBlockParam({ 219: answers, 220: annotations 221: }, toolUseID) { 222: const answersText = Object.entries(answers).map(([questionText, answer]) => { 223: const annotation = annotations?.[questionText]; 224: const parts = [`"${questionText}"="${answer}"`]; 225: if (annotation?.preview) { 226: parts.push(`selected preview:\n${annotation.preview}`); 227: } 228: if (annotation?.notes) { 229: parts.push(`user notes: ${annotation.notes}`); 230: } 231: return parts.join(' '); 232: }).join(', '); 233: return { 234: type: 'tool_result', 235: content: `User has answered your questions: ${answersText}. You can now continue with the user's answers in mind.`, 236: tool_use_id: toolUseID 237: }; 238: } 239: } satisfies ToolDef<InputSchema, Output>); 240: function validateHtmlPreview(preview: string | undefined): string | null { 241: if (preview === undefined) return null; 242: if (/<\s*(html|body|!doctype)\b/i.test(preview)) { 243: return 'preview must be an HTML fragment, not a full document (no <html>, <body>, or <!DOCTYPE>)'; 244: } 245: if (/<\s*(script|style)\b/i.test(preview)) { 246: return 'preview must not contain <script> or <style> tags. Use inline styles via the style attribute if needed.'; 247: } 248: if (!/<[a-z][^>]*>/i.test(preview)) { 249: return 'preview must contain HTML (previewFormat is set to "html"). Wrap content in a tag like <div> or <pre>.'; 250: } 251: return null; 252: }

File: src/tools/AskUserQuestionTool/prompt.ts

typescript 1: import { EXIT_PLAN_MODE_TOOL_NAME } from '../ExitPlanModeTool/constants.js' 2: export const ASK_USER_QUESTION_TOOL_NAME = 'AskUserQuestion' 3: export const ASK_USER_QUESTION_TOOL_CHIP_WIDTH = 12 4: export const DESCRIPTION = 5: 'Asks the user multiple choice questions to gather information, clarify ambiguity, understand preferences, make decisions or offer them choices.' 6: export const PREVIEW_FEATURE_PROMPT = { 7: markdown: ` 8: Preview feature: 9: Use the optional \`preview\` field on options when presenting concrete artifacts that users need to visually compare: 10: - ASCII mockups of UI layouts or components 11: - Code snippets showing different implementations 12: - Diagram variations 13: - Configuration examples 14: Preview content is rendered as markdown in a monospace box. Multi-line text with newlines is supported. When any option has a preview, the UI switches to a side-by-side layout with a vertical option list on the left and preview on the right. Do not use previews for simple preference questions where labels and descriptions suffice. Note: previews are only supported for single-select questions (not multiSelect). 15: `, 16: html: ` 17: Preview feature: 18: Use the optional \`preview\` field on options when presenting concrete artifacts that users need to visually compare: 19: - HTML mockups of UI layouts or components 20: - Formatted code snippets showing different implementations 21: - Visual comparisons or diagrams 22: Preview content must be a self-contained HTML fragment (no <html>/<body> wrapper, no <script> or <style> tags — use inline style attributes instead). Do not use previews for simple preference questions where labels and descriptions suffice. Note: previews are only supported for single-select questions (not multiSelect). 23: `, 24: } as const 25: export const ASK_USER_QUESTION_TOOL_PROMPT = `Use this tool when you need to ask the user questions during execution. This allows you to: 26: 1. Gather user preferences or requirements 27: 2. Clarify ambiguous instructions 28: 3. Get decisions on implementation choices as you work 29: 4. Offer choices to the user about what direction to take. 30: Usage notes: 31: - Users will always be able to select "Other" to provide custom text input 32: - Use multiSelect: true to allow multiple answers to be selected for a question 33: - If you recommend a specific option, make that the first option in the list and add "(Recommended)" at the end of the label 34: Plan mode note: In plan mode, use this tool to clarify requirements or choose between approaches BEFORE finalizing your plan. Do NOT use this tool to ask "Is my plan ready?" or "Should I proceed?" - use ${EXIT_PLAN_MODE_TOOL_NAME} for plan approval. IMPORTANT: Do not reference "the plan" in your questions (e.g., "Do you have feedback about the plan?", "Does the plan look good?") because the user cannot see the plan in the UI until you call ${EXIT_PLAN_MODE_TOOL_NAME}. If you need plan approval, use ${EXIT_PLAN_MODE_TOOL_NAME} instead. 35: `

File: src/tools/BashTool/bashCommandHelpers.ts

typescript 1: import type { z } from 'zod/v4' 2: import { 3: isUnsafeCompoundCommand_DEPRECATED, 4: splitCommand_DEPRECATED, 5: } from '../../utils/bash/commands.js' 6: import { 7: buildParsedCommandFromRoot, 8: type IParsedCommand, 9: ParsedCommand, 10: } from '../../utils/bash/ParsedCommand.js' 11: import { type Node, PARSE_ABORTED } from '../../utils/bash/parser.js' 12: import type { PermissionResult } from '../../utils/permissions/PermissionResult.js' 13: import type { PermissionUpdate } from '../../utils/permissions/PermissionUpdateSchema.js' 14: import { createPermissionRequestMessage } from '../../utils/permissions/permissions.js' 15: import { BashTool } from './BashTool.js' 16: import { bashCommandIsSafeAsync_DEPRECATED } from './bashSecurity.js' 17: export type CommandIdentityCheckers = { 18: isNormalizedCdCommand: (command: string) => boolean 19: isNormalizedGitCommand: (command: string) => boolean 20: } 21: async function segmentedCommandPermissionResult( 22: input: z.infer<typeof BashTool.inputSchema>, 23: segments: string[], 24: bashToolHasPermissionFn: ( 25: input: z.infer<typeof BashTool.inputSchema>, 26: ) => Promise<PermissionResult>, 27: checkers: CommandIdentityCheckers, 28: ): Promise<PermissionResult> { 29: const cdCommands = segments.filter(segment => { 30: const trimmed = segment.trim() 31: return checkers.isNormalizedCdCommand(trimmed) 32: }) 33: if (cdCommands.length > 1) { 34: const decisionReason = { 35: type: 'other' as const, 36: reason: 37: 'Multiple directory changes in one command require approval for clarity', 38: } 39: return { 40: behavior: 'ask', 41: decisionReason, 42: message: createPermissionRequestMessage(BashTool.name, decisionReason), 43: } 44: } 45: { 46: let hasCd = false 47: let hasGit = false 48: for (const segment of segments) { 49: const subcommands = splitCommand_DEPRECATED(segment) 50: for (const sub of subcommands) { 51: const trimmed = sub.trim() 52: if (checkers.isNormalizedCdCommand(trimmed)) { 53: hasCd = true 54: } 55: if (checkers.isNormalizedGitCommand(trimmed)) { 56: hasGit = true 57: } 58: } 59: } 60: if (hasCd && hasGit) { 61: const decisionReason = { 62: type: 'other' as const, 63: reason: 64: 'Compound commands with cd and git require approval to prevent bare repository attacks', 65: } 66: return { 67: behavior: 'ask', 68: decisionReason, 69: message: createPermissionRequestMessage(BashTool.name, decisionReason), 70: } 71: } 72: } 73: const segmentResults = new Map<string, PermissionResult>() 74: for (const segment of segments) { 75: const trimmedSegment = segment.trim() 76: if (!trimmedSegment) continue 77: const segmentResult = await bashToolHasPermissionFn({ 78: ...input, 79: command: trimmedSegment, 80: }) 81: segmentResults.set(trimmedSegment, segmentResult) 82: } 83: const deniedSegment = Array.from(segmentResults.entries()).find( 84: ([, result]) => result.behavior === 'deny', 85: ) 86: if (deniedSegment) { 87: const [segmentCommand, segmentResult] = deniedSegment 88: return { 89: behavior: 'deny', 90: message: 91: segmentResult.behavior === 'deny' 92: ? segmentResult.message 93: : `Permission denied for: ${segmentCommand}`, 94: decisionReason: { 95: type: 'subcommandResults', 96: reasons: segmentResults, 97: }, 98: } 99: } 100: const allAllowed = Array.from(segmentResults.values()).every( 101: result => result.behavior === 'allow', 102: ) 103: if (allAllowed) { 104: return { 105: behavior: 'allow', 106: updatedInput: input, 107: decisionReason: { 108: type: 'subcommandResults', 109: reasons: segmentResults, 110: }, 111: } 112: } 113: const suggestions: PermissionUpdate[] = [] 114: for (const [, result] of segmentResults) { 115: if ( 116: result.behavior !== 'allow' && 117: 'suggestions' in result && 118: result.suggestions 119: ) { 120: suggestions.push(...result.suggestions) 121: } 122: } 123: const decisionReason = { 124: type: 'subcommandResults' as const, 125: reasons: segmentResults, 126: } 127: return { 128: behavior: 'ask', 129: message: createPermissionRequestMessage(BashTool.name, decisionReason), 130: decisionReason, 131: suggestions: suggestions.length > 0 ? suggestions : undefined, 132: } 133: } 134: async function buildSegmentWithoutRedirections( 135: segmentCommand: string, 136: ): Promise<string> { 137: if (!segmentCommand.includes('>')) { 138: return segmentCommand 139: } 140: const parsed = await ParsedCommand.parse(segmentCommand) 141: return parsed?.withoutOutputRedirections() ?? segmentCommand 142: } 143: export async function checkCommandOperatorPermissions( 144: input: z.infer<typeof BashTool.inputSchema>, 145: bashToolHasPermissionFn: ( 146: input: z.infer<typeof BashTool.inputSchema>, 147: ) => Promise<PermissionResult>, 148: checkers: CommandIdentityCheckers, 149: astRoot: Node | null | typeof PARSE_ABORTED, 150: ): Promise<PermissionResult> { 151: const parsed = 152: astRoot && astRoot !== PARSE_ABORTED 153: ? buildParsedCommandFromRoot(input.command, astRoot) 154: : await ParsedCommand.parse(input.command) 155: if (!parsed) { 156: return { behavior: 'passthrough', message: 'Failed to parse command' } 157: } 158: return bashToolCheckCommandOperatorPermissions( 159: input, 160: bashToolHasPermissionFn, 161: checkers, 162: parsed, 163: ) 164: } 165: async function bashToolCheckCommandOperatorPermissions( 166: input: z.infer<typeof BashTool.inputSchema>, 167: bashToolHasPermissionFn: ( 168: input: z.infer<typeof BashTool.inputSchema>, 169: ) => Promise<PermissionResult>, 170: checkers: CommandIdentityCheckers, 171: parsed: IParsedCommand, 172: ): Promise<PermissionResult> { 173: const tsAnalysis = parsed.getTreeSitterAnalysis() 174: const isUnsafeCompound = tsAnalysis 175: ? tsAnalysis.compoundStructure.hasSubshell || 176: tsAnalysis.compoundStructure.hasCommandGroup 177: : isUnsafeCompoundCommand_DEPRECATED(input.command) 178: if (isUnsafeCompound) { 179: const safetyResult = await bashCommandIsSafeAsync_DEPRECATED(input.command) 180: const decisionReason = { 181: type: 'other' as const, 182: reason: 183: safetyResult.behavior === 'ask' && safetyResult.message 184: ? safetyResult.message 185: : 'This command uses shell operators that require approval for safety', 186: } 187: return { 188: behavior: 'ask', 189: message: createPermissionRequestMessage(BashTool.name, decisionReason), 190: decisionReason, 191: } 192: } 193: const pipeSegments = parsed.getPipeSegments() 194: if (pipeSegments.length <= 1) { 195: return { 196: behavior: 'passthrough', 197: message: 'No pipes found in command', 198: } 199: } 200: const segments = await Promise.all( 201: pipeSegments.map(segment => buildSegmentWithoutRedirections(segment)), 202: ) 203: return segmentedCommandPermissionResult( 204: input, 205: segments, 206: bashToolHasPermissionFn, 207: checkers, 208: ) 209: }

File: src/tools/BashTool/bashPermissions.ts

typescript 1: import { feature } from 'bun:bundle' 2: import { APIUserAbortError } from '@anthropic-ai/sdk' 3: import type { z } from 'zod/v4' 4: import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js' 5: import { 6: type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 7: logEvent, 8: } from '../../services/analytics/index.js' 9: import type { ToolPermissionContext, ToolUseContext } from '../../Tool.js' 10: import type { PendingClassifierCheck } from '../../types/permissions.js' 11: import { count } from '../../utils/array.js' 12: import { 13: checkSemantics, 14: nodeTypeId, 15: type ParseForSecurityResult, 16: parseForSecurityFromAst, 17: type Redirect, 18: type SimpleCommand, 19: } from '../../utils/bash/ast.js' 20: import { 21: type CommandPrefixResult, 22: extractOutputRedirections, 23: getCommandSubcommandPrefix, 24: splitCommand_DEPRECATED, 25: } from '../../utils/bash/commands.js' 26: import { parseCommandRaw } from '../../utils/bash/parser.js' 27: import { tryParseShellCommand } from '../../utils/bash/shellQuote.js' 28: import { getCwd } from '../../utils/cwd.js' 29: import { logForDebugging } from '../../utils/debug.js' 30: import { isEnvTruthy } from '../../utils/envUtils.js' 31: import { AbortError } from '../../utils/errors.js' 32: import type { 33: ClassifierBehavior, 34: ClassifierResult, 35: } from '../../utils/permissions/bashClassifier.js' 36: import { 37: classifyBashCommand, 38: getBashPromptAllowDescriptions, 39: getBashPromptAskDescriptions, 40: getBashPromptDenyDescriptions, 41: isClassifierPermissionsEnabled, 42: } from '../../utils/permissions/bashClassifier.js' 43: import type { 44: PermissionDecisionReason, 45: PermissionResult, 46: } from '../../utils/permissions/PermissionResult.js' 47: import type { 48: PermissionRule, 49: PermissionRuleValue, 50: } from '../../utils/permissions/PermissionRule.js' 51: import { extractRules } from '../../utils/permissions/PermissionUpdate.js' 52: import type { PermissionUpdate } from '../../utils/permissions/PermissionUpdateSchema.js' 53: import { permissionRuleValueToString } from '../../utils/permissions/permissionRuleParser.js' 54: import { 55: createPermissionRequestMessage, 56: getRuleByContentsForTool, 57: } from '../../utils/permissions/permissions.js' 58: import { 59: parsePermissionRule, 60: type ShellPermissionRule, 61: matchWildcardPattern as sharedMatchWildcardPattern, 62: permissionRuleExtractPrefix as sharedPermissionRuleExtractPrefix, 63: suggestionForExactCommand as sharedSuggestionForExactCommand, 64: suggestionForPrefix as sharedSuggestionForPrefix, 65: } from '../../utils/permissions/shellRuleMatching.js' 66: import { getPlatform } from '../../utils/platform.js' 67: import { SandboxManager } from '../../utils/sandbox/sandbox-adapter.js' 68: import { jsonStringify } from '../../utils/slowOperations.js' 69: import { windowsPathToPosixPath } from '../../utils/windowsPaths.js' 70: import { BashTool } from './BashTool.js' 71: import { checkCommandOperatorPermissions } from './bashCommandHelpers.js' 72: import { 73: bashCommandIsSafeAsync_DEPRECATED, 74: stripSafeHeredocSubstitutions, 75: } from './bashSecurity.js' 76: import { checkPermissionMode } from './modeValidation.js' 77: import { checkPathConstraints } from './pathValidation.js' 78: import { checkSedConstraints } from './sedValidation.js' 79: import { shouldUseSandbox } from './shouldUseSandbox.js' 80: const bashCommandIsSafeAsync = bashCommandIsSafeAsync_DEPRECATED 81: const splitCommand = splitCommand_DEPRECATED 82: const ENV_VAR_ASSIGN_RE = /^[A-Za-z_]\w*=/ 83: export const MAX_SUBCOMMANDS_FOR_SECURITY_CHECK = 50 84: export const MAX_SUGGESTED_RULES_FOR_COMPOUND = 5 85: function logClassifierResultForAnts( 86: command: string, 87: behavior: ClassifierBehavior, 88: descriptions: string[], 89: result: ClassifierResult, 90: ): void { 91: if (process.env.USER_TYPE !== 'ant') { 92: return 93: } 94: logEvent('tengu_internal_bash_classifier_result', { 95: behavior: 96: behavior as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 97: descriptions: jsonStringify( 98: descriptions, 99: ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 100: matches: result.matches, 101: matchedDescription: (result.matchedDescription ?? 102: '') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 103: confidence: 104: result.confidence as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 105: reason: 106: result.reason as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 107: // Note: command contains code/filepaths - this is ANT-ONLY so it's OK 108: command: 109: command as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 110: }) 111: } 112: export function getSimpleCommandPrefix(command: string): string | null { 113: const tokens = command.trim().split(/\s+/).filter(Boolean) 114: if (tokens.length === 0) return null 115: let i = 0 116: while (i < tokens.length && ENV_VAR_ASSIGN_RE.test(tokens[i]!)) { 117: const varName = tokens[i]!.split('=')[0]! 118: const isAntOnlySafe = 119: process.env.USER_TYPE === 'ant' && ANT_ONLY_SAFE_ENV_VARS.has(varName) 120: if (!SAFE_ENV_VARS.has(varName) && !isAntOnlySafe) { 121: return null 122: } 123: i++ 124: } 125: const remaining = tokens.slice(i) 126: if (remaining.length < 2) return null 127: const subcmd = remaining[1]! 128: if (!/^[a-z][a-z0-9]*(-[a-z0-9]+)*$/.test(subcmd)) return null 129: return remaining.slice(0, 2).join(' ') 130: } 131: const BARE_SHELL_PREFIXES = new Set([ 132: 'sh', 133: 'bash', 134: 'zsh', 135: 'fish', 136: 'csh', 137: 'tcsh', 138: 'ksh', 139: 'dash', 140: 'cmd', 141: 'powershell', 142: 'pwsh', 143: 'env', 144: 'xargs', 145: 'nice', 146: 'stdbuf', 147: 'nohup', 148: 'timeout', 149: 'time', 150: 'sudo', 151: 'doas', 152: 'pkexec', 153: ]) 154: export function getFirstWordPrefix(command: string): string | null { 155: const tokens = command.trim().split(/\s+/).filter(Boolean) 156: let i = 0 157: while (i < tokens.length && ENV_VAR_ASSIGN_RE.test(tokens[i]!)) { 158: const varName = tokens[i]!.split('=')[0]! 159: const isAntOnlySafe = 160: process.env.USER_TYPE === 'ant' && ANT_ONLY_SAFE_ENV_VARS.has(varName) 161: if (!SAFE_ENV_VARS.has(varName) && !isAntOnlySafe) { 162: return null 163: } 164: i++ 165: } 166: const cmd = tokens[i] 167: if (!cmd) return null 168: if (!/^[a-z][a-z0-9]*(-[a-z0-9]+)*$/.test(cmd)) return null 169: if (BARE_SHELL_PREFIXES.has(cmd)) return null 170: return cmd 171: } 172: function suggestionForExactCommand(command: string): PermissionUpdate[] { 173: const heredocPrefix = extractPrefixBeforeHeredoc(command) 174: if (heredocPrefix) { 175: return sharedSuggestionForPrefix(BashTool.name, heredocPrefix) 176: } 177: if (command.includes('\n')) { 178: const firstLine = command.split('\n')[0]!.trim() 179: if (firstLine) { 180: return sharedSuggestionForPrefix(BashTool.name, firstLine) 181: } 182: } 183: const prefix = getSimpleCommandPrefix(command) 184: if (prefix) { 185: return sharedSuggestionForPrefix(BashTool.name, prefix) 186: } 187: return sharedSuggestionForExactCommand(BashTool.name, command) 188: } 189: function extractPrefixBeforeHeredoc(command: string): string | null { 190: if (!command.includes('<<')) return null 191: const idx = command.indexOf('<<') 192: if (idx <= 0) return null 193: const before = command.substring(0, idx).trim() 194: if (!before) return null 195: const prefix = getSimpleCommandPrefix(before) 196: if (prefix) return prefix 197: const tokens = before.split(/\s+/).filter(Boolean) 198: let i = 0 199: while (i < tokens.length && ENV_VAR_ASSIGN_RE.test(tokens[i]!)) { 200: const varName = tokens[i]!.split('=')[0]! 201: const isAntOnlySafe = 202: process.env.USER_TYPE === 'ant' && ANT_ONLY_SAFE_ENV_VARS.has(varName) 203: if (!SAFE_ENV_VARS.has(varName) && !isAntOnlySafe) { 204: return null 205: } 206: i++ 207: } 208: if (i >= tokens.length) return null 209: return tokens.slice(i, i + 2).join(' ') || null 210: } 211: function suggestionForPrefix(prefix: string): PermissionUpdate[] { 212: return sharedSuggestionForPrefix(BashTool.name, prefix) 213: } 214: export const permissionRuleExtractPrefix = sharedPermissionRuleExtractPrefix 215: export function matchWildcardPattern( 216: pattern: string, 217: command: string, 218: ): boolean { 219: return sharedMatchWildcardPattern(pattern, command) 220: } 221: export const bashPermissionRule: ( 222: permissionRule: string, 223: ) => ShellPermissionRule = parsePermissionRule 224: const SAFE_ENV_VARS = new Set([ 225: 'GOEXPERIMENT', 226: 'GOOS', 227: 'GOARCH', 228: 'CGO_ENABLED', 229: 'GO111MODULE', 230: 'RUST_BACKTRACE', 231: 'RUST_LOG', 232: 'NODE_ENV', 233: 'PYTHONUNBUFFERED', 234: 'PYTHONDONTWRITEBYTECODE', 235: 'PYTEST_DISABLE_PLUGIN_AUTOLOAD', 236: 'PYTEST_DEBUG', 237: 'ANTHROPIC_API_KEY', 238: 'LANG', 239: 'LANGUAGE', 240: 'LC_ALL', 241: 'LC_CTYPE', 242: 'LC_TIME', 243: 'CHARSET', 244: 'TERM', 245: 'COLORTERM', 246: 'NO_COLOR', 247: 'FORCE_COLOR', 248: 'TZ', 249: 'LS_COLORS', 250: 'LSCOLORS', 251: 'GREP_COLOR', 252: 'GREP_COLORS', 253: 'GCC_COLORS', 254: 'TIME_STYLE', 255: 'BLOCK_SIZE', 256: 'BLOCKSIZE', 257: ]) 258: const ANT_ONLY_SAFE_ENV_VARS = new Set([ 259: 'KUBECONFIG', 260: 'DOCKER_HOST', 261: 'AWS_PROFILE', 262: 'CLOUDSDK_CORE_PROJECT', 263: 'CLUSTER', 264: 'COO_CLUSTER', 265: 'COO_CLUSTER_NAME', 266: 'COO_NAMESPACE', 267: 'COO_LAUNCH_YAML_DRY_RUN', 268: 'SKIP_NODE_VERSION_CHECK', 269: 'EXPECTTEST_ACCEPT', 270: 'CI', 271: 'GIT_LFS_SKIP_SMUDGE', 272: 'CUDA_VISIBLE_DEVICES', 273: 'JAX_PLATFORMS', 274: 'COLUMNS', 275: 'TMUX', 276: 'POSTGRESQL_VERSION', 277: 'FIRESTORE_EMULATOR_HOST', 278: 'HARNESS_QUIET', 279: 'TEST_CROSSCHECK_LISTS_MATCH_UPDATE', 280: 'DBT_PER_DEVELOPER_ENVIRONMENTS', 281: 'STATSIG_FORD_DB_CHECKS', 282: 'ANT_ENVIRONMENT', 283: 'ANT_SERVICE', 284: 'MONOREPO_ROOT_DIR', 285: 'PYENV_VERSION', 286: 'PGPASSWORD', 287: 'GH_TOKEN', 288: 'GROWTHBOOK_API_KEY', 289: ]) 290: function stripCommentLines(command: string): string { 291: const lines = command.split('\n') 292: const nonCommentLines = lines.filter(line => { 293: const trimmed = line.trim() 294: return trimmed !== '' && !trimmed.startsWith('#') 295: }) 296: // If all lines were comments/empty, return original 297: if (nonCommentLines.length === 0) { 298: return command 299: } 300: return nonCommentLines.join('\n') 301: } 302: export function stripSafeWrappers(command: string): string { 303: const SAFE_WRAPPER_PATTERNS = [ 304: /^timeout[ \t]+(?:(?:--(?:foreground|preserve-status|verbose)|--(?:kill-after|signal)=[A-Za-z0-9_.+-]+|--(?:kill-after|signal)[ \t]+[A-Za-z0-9_.+-]+|-v|-[ks][ \t]+[A-Za-z0-9_.+-]+|-[ks][A-Za-z0-9_.+-]+)[ \t]+)*(?:--[ \t]+)?\d+(?:\.\d+)?[smhd]?[ \t]+/, 305: /^time[ \t]+(?:--[ \t]+)?/, 306: /^nice(?:[ \t]+-n[ \t]+-?\d+|[ \t]+-\d+)?[ \t]+(?:--[ \t]+)?/, 307: /^stdbuf(?:[ \t]+-[ioe][LN0-9]+)+[ \t]+(?:--[ \t]+)?/, 308: /^nohup[ \t]+(?:--[ \t]+)?/, 309: ] as const 310: const ENV_VAR_PATTERN = /^([A-Za-z_][A-Za-z0-9_]*)=([A-Za-z0-9_./:-]+)[ \t]+/ 311: let stripped = command 312: let previousStripped = '' 313: // Phase 1: Strip leading env vars and comments only. 314: // In bash, env var assignments before a command (VAR=val cmd) are genuine 315: // shell-level assignments. These are safe to strip for permission matching. 316: while (stripped !== previousStripped) { 317: previousStripped = stripped 318: stripped = stripCommentLines(stripped) 319: const envVarMatch = stripped.match(ENV_VAR_PATTERN) 320: if (envVarMatch) { 321: const varName = envVarMatch[1]! 322: const isAntOnlySafe = 323: process.env.USER_TYPE === 'ant' && ANT_ONLY_SAFE_ENV_VARS.has(varName) 324: if (SAFE_ENV_VARS.has(varName) || isAntOnlySafe) { 325: stripped = stripped.replace(ENV_VAR_PATTERN, '') 326: } 327: } 328: } 329: // Phase 2: Strip wrapper commands and comments only. Do NOT strip env vars. 330: // Wrapper commands (timeout, time, nice, nohup) use execvp to run their 331: // arguments, so VAR=val after a wrapper is treated as the COMMAND to execute, 332: // not as an env var assignment. Stripping env vars here would create a 333: // mismatch between what the parser sees and what actually executes. 334: // (HackerOne #3543050) 335: previousStripped = '' 336: while (stripped !== previousStripped) { 337: previousStripped = stripped 338: stripped = stripCommentLines(stripped) 339: for (const pattern of SAFE_WRAPPER_PATTERNS) { 340: stripped = stripped.replace(pattern, '') 341: } 342: } 343: return stripped.trim() 344: } 345: // SECURITY: allowlist for timeout flag VALUES (signals are TERM/KILL/9, 346: // durations are 5/5s/10.5). Rejects $ ( ) ` | ; & and newlines that 347: // previously matched via [^ \t]+ — `timeout -k$(id) 10 ls` must NOT strip. 348: const TIMEOUT_FLAG_VALUE_RE = /^[A-Za-z0-9_.+-]+$/ 349: /** 350: * Parse timeout's GNU flags (long + short, fused + space-separated) and 351: * return the argv index of the DURATION token, or -1 if flags are unparseable. 352: * Enumerates: --foreground/--preserve-status/--verbose (no value), 353: * --kill-after/--signal (value, both =fused and space-separated), -v (no 354: * value), -k/-s (value, both fused and space-separated). 355: * 356: * Extracted from stripWrappersFromArgv to keep bashToolHasPermission under 357: * Bun's feature() DCE complexity threshold — inlining this breaks 358: * feature('BASH_CLASSIFIER') evaluation in classifier tests. 359: */ 360: function skipTimeoutFlags(a: readonly string[]): number { 361: let i = 1 362: while (i < a.length) { 363: const arg = a[i]! 364: const next = a[i + 1] 365: if ( 366: arg === '--foreground' || 367: arg === '--preserve-status' || 368: arg === '--verbose' 369: ) 370: i++ 371: else if (/^--(?:kill-after|signal)=[A-Za-z0-9_.+-]+$/.test(arg)) i++ 372: else if ( 373: (arg === '--kill-after' || arg === '--signal') && 374: next && 375: TIMEOUT_FLAG_VALUE_RE.test(next) 376: ) 377: i += 2 378: else if (arg === '--') { 379: i++ 380: break 381: } 382: else if (arg.startsWith('--')) return -1 383: else if (arg === '-v') i++ 384: else if ( 385: (arg === '-k' || arg === '-s') && 386: next && 387: TIMEOUT_FLAG_VALUE_RE.test(next) 388: ) 389: i += 2 390: else if (/^-[ks][A-Za-z0-9_.+-]+$/.test(arg)) i++ 391: else if (arg.startsWith('-')) return -1 392: else break 393: } 394: return i 395: } 396: export function stripWrappersFromArgv(argv: string[]): string[] { 397: let a = argv 398: for (;;) { 399: if (a[0] === 'time' || a[0] === 'nohup') { 400: a = a.slice(a[1] === '--' ? 2 : 1) 401: } else if (a[0] === 'timeout') { 402: const i = skipTimeoutFlags(a) 403: if (i < 0 || !a[i] || !/^\d+(?:\.\d+)?[smhd]?$/.test(a[i]!)) return a 404: a = a.slice(i + 1) 405: } else if ( 406: a[0] === 'nice' && 407: a[1] === '-n' && 408: a[2] && 409: /^-?\d+$/.test(a[2]) 410: ) { 411: a = a.slice(a[3] === '--' ? 4 : 3) 412: } else { 413: return a 414: } 415: } 416: } 417: export const BINARY_HIJACK_VARS = /^(LD_|DYLD_|PATH$)/ 418: export function stripAllLeadingEnvVars( 419: command: string, 420: blocklist?: RegExp, 421: ): string { 422: const ENV_VAR_PATTERN = 423: /^([A-Za-z_][A-Za-z0-9_]*(?:\[[^\]]*\])?)\+?=(?:'[^'\n\r]*'|"(?:\\.|[^"$`\\\n\r])*"|\\.|[^ \t\n\r$`;|&()<>\\\\'"])*[ \t]+/ 424: let stripped = command 425: let previousStripped = '' 426: while (stripped !== previousStripped) { 427: previousStripped = stripped 428: stripped = stripCommentLines(stripped) 429: const m = stripped.match(ENV_VAR_PATTERN) 430: if (!m) continue 431: if (blocklist?.test(m[1]!)) break 432: stripped = stripped.slice(m[0].length) 433: } 434: return stripped.trim() 435: } 436: function filterRulesByContentsMatchingInput( 437: input: z.infer<typeof BashTool.inputSchema>, 438: rules: Map<string, PermissionRule>, 439: matchMode: 'exact' | 'prefix', 440: { 441: stripAllEnvVars = false, 442: skipCompoundCheck = false, 443: }: { stripAllEnvVars?: boolean; skipCompoundCheck?: boolean } = {}, 444: ): PermissionRule[] { 445: const command = input.command.trim() 446: const commandWithoutRedirections = 447: extractOutputRedirections(command).commandWithoutRedirections 448: const commandsForMatching = 449: matchMode === 'exact' 450: ? [command, commandWithoutRedirections] 451: : [commandWithoutRedirections] 452: const commandsToTry = commandsForMatching.flatMap(cmd => { 453: const strippedCommand = stripSafeWrappers(cmd) 454: return strippedCommand !== cmd ? [cmd, strippedCommand] : [cmd] 455: }) 456: if (stripAllEnvVars) { 457: const seen = new Set(commandsToTry) 458: let startIdx = 0 459: while (startIdx < commandsToTry.length) { 460: const endIdx = commandsToTry.length 461: for (let i = startIdx; i < endIdx; i++) { 462: const cmd = commandsToTry[i] 463: if (!cmd) { 464: continue 465: } 466: const envStripped = stripAllLeadingEnvVars(cmd) 467: if (!seen.has(envStripped)) { 468: commandsToTry.push(envStripped) 469: seen.add(envStripped) 470: } 471: const wrapperStripped = stripSafeWrappers(cmd) 472: if (!seen.has(wrapperStripped)) { 473: commandsToTry.push(wrapperStripped) 474: seen.add(wrapperStripped) 475: } 476: } 477: startIdx = endIdx 478: } 479: } 480: const isCompoundCommand = new Map<string, boolean>() 481: if (matchMode === 'prefix' && !skipCompoundCheck) { 482: for (const cmd of commandsToTry) { 483: if (!isCompoundCommand.has(cmd)) { 484: isCompoundCommand.set(cmd, splitCommand(cmd).length > 1) 485: } 486: } 487: } 488: return Array.from(rules.entries()) 489: .filter(([ruleContent]) => { 490: const bashRule = bashPermissionRule(ruleContent) 491: return commandsToTry.some(cmdToMatch => { 492: switch (bashRule.type) { 493: case 'exact': 494: return bashRule.command === cmdToMatch 495: case 'prefix': 496: switch (matchMode) { 497: case 'exact': 498: return bashRule.prefix === cmdToMatch 499: case 'prefix': { 500: if (isCompoundCommand.get(cmdToMatch)) { 501: return false 502: } 503: if (cmdToMatch === bashRule.prefix) { 504: return true 505: } 506: if (cmdToMatch.startsWith(bashRule.prefix + ' ')) { 507: return true 508: } 509: const xargsPrefix = 'xargs ' + bashRule.prefix 510: if (cmdToMatch === xargsPrefix) { 511: return true 512: } 513: return cmdToMatch.startsWith(xargsPrefix + ' ') 514: } 515: } 516: break 517: case 'wildcard': 518: if (matchMode === 'exact') { 519: return false 520: } 521: if (isCompoundCommand.get(cmdToMatch)) { 522: return false 523: } 524: return matchWildcardPattern(bashRule.pattern, cmdToMatch) 525: } 526: }) 527: }) 528: .map(([, rule]) => rule) 529: } 530: function matchingRulesForInput( 531: input: z.infer<typeof BashTool.inputSchema>, 532: toolPermissionContext: ToolPermissionContext, 533: matchMode: 'exact' | 'prefix', 534: { skipCompoundCheck = false }: { skipCompoundCheck?: boolean } = {}, 535: ) { 536: const denyRuleByContents = getRuleByContentsForTool( 537: toolPermissionContext, 538: BashTool, 539: 'deny', 540: ) 541: const matchingDenyRules = filterRulesByContentsMatchingInput( 542: input, 543: denyRuleByContents, 544: matchMode, 545: { stripAllEnvVars: true, skipCompoundCheck: true }, 546: ) 547: const askRuleByContents = getRuleByContentsForTool( 548: toolPermissionContext, 549: BashTool, 550: 'ask', 551: ) 552: const matchingAskRules = filterRulesByContentsMatchingInput( 553: input, 554: askRuleByContents, 555: matchMode, 556: { stripAllEnvVars: true, skipCompoundCheck: true }, 557: ) 558: const allowRuleByContents = getRuleByContentsForTool( 559: toolPermissionContext, 560: BashTool, 561: 'allow', 562: ) 563: const matchingAllowRules = filterRulesByContentsMatchingInput( 564: input, 565: allowRuleByContents, 566: matchMode, 567: { skipCompoundCheck }, 568: ) 569: return { 570: matchingDenyRules, 571: matchingAskRules, 572: matchingAllowRules, 573: } 574: } 575: export const bashToolCheckExactMatchPermission = ( 576: input: z.infer<typeof BashTool.inputSchema>, 577: toolPermissionContext: ToolPermissionContext, 578: ): PermissionResult => { 579: const command = input.command.trim() 580: const { matchingDenyRules, matchingAskRules, matchingAllowRules } = 581: matchingRulesForInput(input, toolPermissionContext, 'exact') 582: if (matchingDenyRules[0] !== undefined) { 583: return { 584: behavior: 'deny', 585: message: `Permission to use ${BashTool.name} with command ${command} has been denied.`, 586: decisionReason: { 587: type: 'rule', 588: rule: matchingDenyRules[0], 589: }, 590: } 591: } 592: if (matchingAskRules[0] !== undefined) { 593: return { 594: behavior: 'ask', 595: message: createPermissionRequestMessage(BashTool.name), 596: decisionReason: { 597: type: 'rule', 598: rule: matchingAskRules[0], 599: }, 600: } 601: } 602: if (matchingAllowRules[0] !== undefined) { 603: return { 604: behavior: 'allow', 605: updatedInput: input, 606: decisionReason: { 607: type: 'rule', 608: rule: matchingAllowRules[0], 609: }, 610: } 611: } 612: const decisionReason = { 613: type: 'other' as const, 614: reason: 'This command requires approval', 615: } 616: return { 617: behavior: 'passthrough', 618: message: createPermissionRequestMessage(BashTool.name, decisionReason), 619: decisionReason, 620: suggestions: suggestionForExactCommand(command), 621: } 622: } 623: export const bashToolCheckPermission = ( 624: input: z.infer<typeof BashTool.inputSchema>, 625: toolPermissionContext: ToolPermissionContext, 626: compoundCommandHasCd?: boolean, 627: astCommand?: SimpleCommand, 628: ): PermissionResult => { 629: const command = input.command.trim() 630: const exactMatchResult = bashToolCheckExactMatchPermission( 631: input, 632: toolPermissionContext, 633: ) 634: if ( 635: exactMatchResult.behavior === 'deny' || 636: exactMatchResult.behavior === 'ask' 637: ) { 638: return exactMatchResult 639: } 640: const { matchingDenyRules, matchingAskRules, matchingAllowRules } = 641: matchingRulesForInput(input, toolPermissionContext, 'prefix', { 642: skipCompoundCheck: astCommand !== undefined, 643: }) 644: if (matchingDenyRules[0] !== undefined) { 645: return { 646: behavior: 'deny', 647: message: `Permission to use ${BashTool.name} with command ${command} has been denied.`, 648: decisionReason: { 649: type: 'rule', 650: rule: matchingDenyRules[0], 651: }, 652: } 653: } 654: if (matchingAskRules[0] !== undefined) { 655: return { 656: behavior: 'ask', 657: message: createPermissionRequestMessage(BashTool.name), 658: decisionReason: { 659: type: 'rule', 660: rule: matchingAskRules[0], 661: }, 662: } 663: } 664: const pathResult = checkPathConstraints( 665: input, 666: getCwd(), 667: toolPermissionContext, 668: compoundCommandHasCd, 669: astCommand?.redirects, 670: astCommand ? [astCommand] : undefined, 671: ) 672: if (pathResult.behavior !== 'passthrough') { 673: return pathResult 674: } 675: if (exactMatchResult.behavior === 'allow') { 676: return exactMatchResult 677: } 678: if (matchingAllowRules[0] !== undefined) { 679: return { 680: behavior: 'allow', 681: updatedInput: input, 682: decisionReason: { 683: type: 'rule', 684: rule: matchingAllowRules[0], 685: }, 686: } 687: } 688: const sedConstraintResult = checkSedConstraints(input, toolPermissionContext) 689: if (sedConstraintResult.behavior !== 'passthrough') { 690: return sedConstraintResult 691: } 692: const modeResult = checkPermissionMode(input, toolPermissionContext) 693: if (modeResult.behavior !== 'passthrough') { 694: return modeResult 695: } 696: if (BashTool.isReadOnly(input)) { 697: return { 698: behavior: 'allow', 699: updatedInput: input, 700: decisionReason: { 701: type: 'other', 702: reason: 'Read-only command is allowed', 703: }, 704: } 705: } 706: const decisionReason = { 707: type: 'other' as const, 708: reason: 'This command requires approval', 709: } 710: return { 711: behavior: 'passthrough', 712: message: createPermissionRequestMessage(BashTool.name, decisionReason), 713: decisionReason, 714: suggestions: suggestionForExactCommand(command), 715: } 716: } 717: export async function checkCommandAndSuggestRules( 718: input: z.infer<typeof BashTool.inputSchema>, 719: toolPermissionContext: ToolPermissionContext, 720: commandPrefixResult: CommandPrefixResult | null | undefined, 721: compoundCommandHasCd?: boolean, 722: astParseSucceeded?: boolean, 723: ): Promise<PermissionResult> { 724: const exactMatchResult = bashToolCheckExactMatchPermission( 725: input, 726: toolPermissionContext, 727: ) 728: if (exactMatchResult.behavior !== 'passthrough') { 729: return exactMatchResult 730: } 731: const permissionResult = bashToolCheckPermission( 732: input, 733: toolPermissionContext, 734: compoundCommandHasCd, 735: ) 736: if ( 737: permissionResult.behavior === 'deny' || 738: permissionResult.behavior === 'ask' 739: ) { 740: return permissionResult 741: } 742: if ( 743: !astParseSucceeded && 744: !isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_COMMAND_INJECTION_CHECK) 745: ) { 746: const safetyResult = await bashCommandIsSafeAsync(input.command) 747: if (safetyResult.behavior !== 'passthrough') { 748: const decisionReason: PermissionDecisionReason = { 749: type: 'other' as const, 750: reason: 751: safetyResult.behavior === 'ask' && safetyResult.message 752: ? safetyResult.message 753: : 'This command contains patterns that could pose security risks and requires approval', 754: } 755: return { 756: behavior: 'ask', 757: message: createPermissionRequestMessage(BashTool.name, decisionReason), 758: decisionReason, 759: suggestions: [], 760: } 761: } 762: } 763: if (permissionResult.behavior === 'allow') { 764: return permissionResult 765: } 766: const suggestedUpdates = commandPrefixResult?.commandPrefix 767: ? suggestionForPrefix(commandPrefixResult.commandPrefix) 768: : suggestionForExactCommand(input.command) 769: return { 770: ...permissionResult, 771: suggestions: suggestedUpdates, 772: } 773: } 774: function checkSandboxAutoAllow( 775: input: z.infer<typeof BashTool.inputSchema>, 776: toolPermissionContext: ToolPermissionContext, 777: ): PermissionResult { 778: const command = input.command.trim() 779: const { matchingDenyRules, matchingAskRules } = matchingRulesForInput( 780: input, 781: toolPermissionContext, 782: 'prefix', 783: ) 784: if (matchingDenyRules[0] !== undefined) { 785: return { 786: behavior: 'deny', 787: message: `Permission to use ${BashTool.name} with command ${command} has been denied.`, 788: decisionReason: { 789: type: 'rule', 790: rule: matchingDenyRules[0], 791: }, 792: } 793: } 794: const subcommands = splitCommand(command) 795: if (subcommands.length > 1) { 796: let firstAskRule: PermissionRule | undefined 797: for (const sub of subcommands) { 798: const subResult = matchingRulesForInput( 799: { command: sub }, 800: toolPermissionContext, 801: 'prefix', 802: ) 803: if (subResult.matchingDenyRules[0] !== undefined) { 804: return { 805: behavior: 'deny', 806: message: `Permission to use ${BashTool.name} with command ${command} has been denied.`, 807: decisionReason: { 808: type: 'rule', 809: rule: subResult.matchingDenyRules[0], 810: }, 811: } 812: } 813: firstAskRule ??= subResult.matchingAskRules[0] 814: } 815: if (firstAskRule) { 816: return { 817: behavior: 'ask', 818: message: createPermissionRequestMessage(BashTool.name), 819: decisionReason: { 820: type: 'rule', 821: rule: firstAskRule, 822: }, 823: } 824: } 825: } 826: if (matchingAskRules[0] !== undefined) { 827: return { 828: behavior: 'ask', 829: message: createPermissionRequestMessage(BashTool.name), 830: decisionReason: { 831: type: 'rule', 832: rule: matchingAskRules[0], 833: }, 834: } 835: } 836: return { 837: behavior: 'allow', 838: updatedInput: input, 839: decisionReason: { 840: type: 'other', 841: reason: 'Auto-allowed with sandbox (autoAllowBashIfSandboxed enabled)', 842: }, 843: } 844: } 845: function filterCdCwdSubcommands( 846: rawSubcommands: string[], 847: astCommands: SimpleCommand[] | undefined, 848: cwd: string, 849: cwdMingw: string, 850: ): { subcommands: string[]; astCommandsByIdx: (SimpleCommand | undefined)[] } { 851: const subcommands: string[] = [] 852: const astCommandsByIdx: (SimpleCommand | undefined)[] = [] 853: for (let i = 0; i < rawSubcommands.length; i++) { 854: const cmd = rawSubcommands[i]! 855: if (cmd === `cd ${cwd}` || cmd === `cd ${cwdMingw}`) continue 856: subcommands.push(cmd) 857: astCommandsByIdx.push(astCommands?.[i]) 858: } 859: return { subcommands, astCommandsByIdx } 860: } 861: function checkEarlyExitDeny( 862: input: z.infer<typeof BashTool.inputSchema>, 863: toolPermissionContext: ToolPermissionContext, 864: ): PermissionResult | null { 865: const exactMatchResult = bashToolCheckExactMatchPermission( 866: input, 867: toolPermissionContext, 868: ) 869: if (exactMatchResult.behavior !== 'passthrough') { 870: return exactMatchResult 871: } 872: const denyMatch = matchingRulesForInput( 873: input, 874: toolPermissionContext, 875: 'prefix', 876: ).matchingDenyRules[0] 877: if (denyMatch !== undefined) { 878: return { 879: behavior: 'deny', 880: message: `Permission to use ${BashTool.name} with command ${input.command} has been denied.`, 881: decisionReason: { type: 'rule', rule: denyMatch }, 882: } 883: } 884: return null 885: } 886: function checkSemanticsDeny( 887: input: z.infer<typeof BashTool.inputSchema>, 888: toolPermissionContext: ToolPermissionContext, 889: commands: readonly { text: string }[], 890: ): PermissionResult | null { 891: const fullCmd = checkEarlyExitDeny(input, toolPermissionContext) 892: if (fullCmd !== null) return fullCmd 893: for (const cmd of commands) { 894: const subDeny = matchingRulesForInput( 895: { ...input, command: cmd.text }, 896: toolPermissionContext, 897: 'prefix', 898: ).matchingDenyRules[0] 899: if (subDeny !== undefined) { 900: return { 901: behavior: 'deny', 902: message: `Permission to use ${BashTool.name} with command ${input.command} has been denied.`, 903: decisionReason: { type: 'rule', rule: subDeny }, 904: } 905: } 906: } 907: return null 908: } 909: function buildPendingClassifierCheck( 910: command: string, 911: toolPermissionContext: ToolPermissionContext, 912: ): { command: string; cwd: string; descriptions: string[] } | undefined { 913: if (!isClassifierPermissionsEnabled()) { 914: return undefined 915: } 916: if (feature('TRANSCRIPT_CLASSIFIER') && toolPermissionContext.mode === 'auto') 917: return undefined 918: if (toolPermissionContext.mode === 'bypassPermissions') return undefined 919: const allowDescriptions = getBashPromptAllowDescriptions( 920: toolPermissionContext, 921: ) 922: if (allowDescriptions.length === 0) return undefined 923: return { 924: command, 925: cwd: getCwd(), 926: descriptions: allowDescriptions, 927: } 928: } 929: const speculativeChecks = new Map<string, Promise<ClassifierResult>>() 930: export function peekSpeculativeClassifierCheck( 931: command: string, 932: ): Promise<ClassifierResult> | undefined { 933: return speculativeChecks.get(command) 934: } 935: export function startSpeculativeClassifierCheck( 936: command: string, 937: toolPermissionContext: ToolPermissionContext, 938: signal: AbortSignal, 939: isNonInteractiveSession: boolean, 940: ): boolean { 941: if (!isClassifierPermissionsEnabled()) return false 942: if (feature('TRANSCRIPT_CLASSIFIER') && toolPermissionContext.mode === 'auto') 943: return false 944: if (toolPermissionContext.mode === 'bypassPermissions') return false 945: const allowDescriptions = getBashPromptAllowDescriptions( 946: toolPermissionContext, 947: ) 948: if (allowDescriptions.length === 0) return false 949: const cwd = getCwd() 950: const promise = classifyBashCommand( 951: command, 952: cwd, 953: allowDescriptions, 954: 'allow', 955: signal, 956: isNonInteractiveSession, 957: ) 958: promise.catch(() => {}) 959: speculativeChecks.set(command, promise) 960: return true 961: } 962: export function consumeSpeculativeClassifierCheck( 963: command: string, 964: ): Promise<ClassifierResult> | undefined { 965: const promise = speculativeChecks.get(command) 966: if (promise) { 967: speculativeChecks.delete(command) 968: } 969: return promise 970: } 971: export function clearSpeculativeChecks(): void { 972: speculativeChecks.clear() 973: } 974: export async function awaitClassifierAutoApproval( 975: pendingCheck: PendingClassifierCheck, 976: signal: AbortSignal, 977: isNonInteractiveSession: boolean, 978: ): Promise<PermissionDecisionReason | undefined> { 979: const { command, cwd, descriptions } = pendingCheck 980: const speculativeResult = consumeSpeculativeClassifierCheck(command) 981: const classifierResult = speculativeResult 982: ? await speculativeResult 983: : await classifyBashCommand( 984: command, 985: cwd, 986: descriptions, 987: 'allow', 988: signal, 989: isNonInteractiveSession, 990: ) 991: logClassifierResultForAnts(command, 'allow', descriptions, classifierResult) 992: if ( 993: feature('BASH_CLASSIFIER') && 994: classifierResult.matches && 995: classifierResult.confidence === 'high' 996: ) { 997: return { 998: type: 'classifier', 999: classifier: 'bash_allow', 1000: reason: `Allowed by prompt rule: "${classifierResult.matchedDescription}"`, 1001: } 1002: } 1003: return undefined 1004: } 1005: type AsyncClassifierCheckCallbacks = { 1006: shouldContinue: () => boolean 1007: onAllow: (decisionReason: PermissionDecisionReason) => void 1008: onComplete?: () => void 1009: } 1010: export async function executeAsyncClassifierCheck( 1011: pendingCheck: { command: string; cwd: string; descriptions: string[] }, 1012: signal: AbortSignal, 1013: isNonInteractiveSession: boolean, 1014: callbacks: AsyncClassifierCheckCallbacks, 1015: ): Promise<void> { 1016: const { command, cwd, descriptions } = pendingCheck 1017: const speculativeResult = consumeSpeculativeClassifierCheck(command) 1018: let classifierResult: ClassifierResult 1019: try { 1020: classifierResult = speculativeResult 1021: ? await speculativeResult 1022: : await classifyBashCommand( 1023: command, 1024: cwd, 1025: descriptions, 1026: 'allow', 1027: signal, 1028: isNonInteractiveSession, 1029: ) 1030: } catch (error: unknown) { 1031: if (error instanceof APIUserAbortError || error instanceof AbortError) { 1032: callbacks.onComplete?.() 1033: return 1034: } 1035: callbacks.onComplete?.() 1036: throw error 1037: } 1038: logClassifierResultForAnts(command, 'allow', descriptions, classifierResult) 1039: if (!callbacks.shouldContinue()) return 1040: if ( 1041: feature('BASH_CLASSIFIER') && 1042: classifierResult.matches && 1043: classifierResult.confidence === 'high' 1044: ) { 1045: callbacks.onAllow({ 1046: type: 'classifier', 1047: classifier: 'bash_allow', 1048: reason: `Allowed by prompt rule: "${classifierResult.matchedDescription}"`, 1049: }) 1050: } else { 1051: callbacks.onComplete?.() 1052: } 1053: } 1054: export async function bashToolHasPermission( 1055: input: z.infer<typeof BashTool.inputSchema>, 1056: context: ToolUseContext, 1057: getCommandSubcommandPrefixFn = getCommandSubcommandPrefix, 1058: ): Promise<PermissionResult> { 1059: let appState = context.getAppState() 1060: const injectionCheckDisabled = isEnvTruthy( 1061: process.env.CLAUDE_CODE_DISABLE_COMMAND_INJECTION_CHECK, 1062: ) 1063: const shadowEnabled = feature('TREE_SITTER_BASH_SHADOW') 1064: ? getFeatureValue_CACHED_MAY_BE_STALE('tengu_birch_trellis', true) 1065: : false 1066: let astRoot = injectionCheckDisabled 1067: ? null 1068: : feature('TREE_SITTER_BASH_SHADOW') && !shadowEnabled 1069: ? null 1070: : await parseCommandRaw(input.command) 1071: let astResult: ParseForSecurityResult = astRoot 1072: ? parseForSecurityFromAst(input.command, astRoot) 1073: : { kind: 'parse-unavailable' } 1074: let astSubcommands: string[] | null = null 1075: let astRedirects: Redirect[] | undefined 1076: let astCommands: SimpleCommand[] | undefined 1077: let shadowLegacySubs: string[] | undefined 1078: if (feature('TREE_SITTER_BASH_SHADOW')) { 1079: const available = astResult.kind !== 'parse-unavailable' 1080: let tooComplex = false 1081: let semanticFail = false 1082: let subsDiffer = false 1083: if (available) { 1084: tooComplex = astResult.kind === 'too-complex' 1085: semanticFail = 1086: astResult.kind === 'simple' && !checkSemantics(astResult.commands).ok 1087: const tsSubs = 1088: astResult.kind === 'simple' 1089: ? astResult.commands.map(c => c.text) 1090: : undefined 1091: const legacySubs = splitCommand(input.command) 1092: shadowLegacySubs = legacySubs 1093: subsDiffer = 1094: tsSubs !== undefined && 1095: (tsSubs.length !== legacySubs.length || 1096: tsSubs.some((s, i) => s !== legacySubs[i])) 1097: } 1098: logEvent('tengu_tree_sitter_shadow', { 1099: available, 1100: astTooComplex: tooComplex, 1101: astSemanticFail: semanticFail, 1102: subsDiffer, 1103: injectionCheckDisabled, 1104: killswitchOff: !shadowEnabled, 1105: cmdOverLength: input.command.length > 10000, 1106: }) 1107: astResult = { kind: 'parse-unavailable' } 1108: astRoot = null 1109: } 1110: if (astResult.kind === 'too-complex') { 1111: const earlyExit = checkEarlyExitDeny(input, appState.toolPermissionContext) 1112: if (earlyExit !== null) return earlyExit 1113: const decisionReason: PermissionDecisionReason = { 1114: type: 'other' as const, 1115: reason: astResult.reason, 1116: } 1117: logEvent('tengu_bash_ast_too_complex', { 1118: nodeTypeId: nodeTypeId(astResult.nodeType), 1119: }) 1120: return { 1121: behavior: 'ask', 1122: decisionReason, 1123: message: createPermissionRequestMessage(BashTool.name, decisionReason), 1124: suggestions: [], 1125: ...(feature('BASH_CLASSIFIER') 1126: ? { 1127: pendingClassifierCheck: buildPendingClassifierCheck( 1128: input.command, 1129: appState.toolPermissionContext, 1130: ), 1131: } 1132: : {}), 1133: } 1134: } 1135: if (astResult.kind === 'simple') { 1136: const sem = checkSemantics(astResult.commands) 1137: if (!sem.ok) { 1138: const earlyExit = checkSemanticsDeny( 1139: input, 1140: appState.toolPermissionContext, 1141: astResult.commands, 1142: ) 1143: if (earlyExit !== null) return earlyExit 1144: const decisionReason: PermissionDecisionReason = { 1145: type: 'other' as const, 1146: reason: sem.reason, 1147: } 1148: return { 1149: behavior: 'ask', 1150: decisionReason, 1151: message: createPermissionRequestMessage(BashTool.name, decisionReason), 1152: suggestions: [], 1153: } 1154: } 1155: astSubcommands = astResult.commands.map(c => c.text) 1156: astRedirects = astResult.commands.flatMap(c => c.redirects) 1157: astCommands = astResult.commands 1158: } 1159: if (astResult.kind === 'parse-unavailable') { 1160: logForDebugging( 1161: 'bashToolHasPermission: tree-sitter unavailable, using legacy shell-quote path', 1162: ) 1163: const parseResult = tryParseShellCommand(input.command) 1164: if (!parseResult.success) { 1165: const decisionReason = { 1166: type: 'other' as const, 1167: reason: `Command contains malformed syntax that cannot be parsed: ${parseResult.error}`, 1168: } 1169: return { 1170: behavior: 'ask', 1171: decisionReason, 1172: message: createPermissionRequestMessage(BashTool.name, decisionReason), 1173: } 1174: } 1175: } 1176: if ( 1177: SandboxManager.isSandboxingEnabled() && 1178: SandboxManager.isAutoAllowBashIfSandboxedEnabled() && 1179: shouldUseSandbox(input) 1180: ) { 1181: const sandboxAutoAllowResult = checkSandboxAutoAllow( 1182: input, 1183: appState.toolPermissionContext, 1184: ) 1185: if (sandboxAutoAllowResult.behavior !== 'passthrough') { 1186: return sandboxAutoAllowResult 1187: } 1188: } 1189: const exactMatchResult = bashToolCheckExactMatchPermission( 1190: input, 1191: appState.toolPermissionContext, 1192: ) 1193: if (exactMatchResult.behavior === 'deny') { 1194: return exactMatchResult 1195: } 1196: if ( 1197: isClassifierPermissionsEnabled() && 1198: !( 1199: feature('TRANSCRIPT_CLASSIFIER') && 1200: appState.toolPermissionContext.mode === 'auto' 1201: ) 1202: ) { 1203: const denyDescriptions = getBashPromptDenyDescriptions( 1204: appState.toolPermissionContext, 1205: ) 1206: const askDescriptions = getBashPromptAskDescriptions( 1207: appState.toolPermissionContext, 1208: ) 1209: const hasDeny = denyDescriptions.length > 0 1210: const hasAsk = askDescriptions.length > 0 1211: if (hasDeny || hasAsk) { 1212: const [denyResult, askResult] = await Promise.all([ 1213: hasDeny 1214: ? classifyBashCommand( 1215: input.command, 1216: getCwd(), 1217: denyDescriptions, 1218: 'deny', 1219: context.abortController.signal, 1220: context.options.isNonInteractiveSession, 1221: ) 1222: : null, 1223: hasAsk 1224: ? classifyBashCommand( 1225: input.command, 1226: getCwd(), 1227: askDescriptions, 1228: 'ask', 1229: context.abortController.signal, 1230: context.options.isNonInteractiveSession, 1231: ) 1232: : null, 1233: ]) 1234: if (context.abortController.signal.aborted) { 1235: throw new AbortError() 1236: } 1237: if (denyResult) { 1238: logClassifierResultForAnts( 1239: input.command, 1240: 'deny', 1241: denyDescriptions, 1242: denyResult, 1243: ) 1244: } 1245: if (askResult) { 1246: logClassifierResultForAnts( 1247: input.command, 1248: 'ask', 1249: askDescriptions, 1250: askResult, 1251: ) 1252: } 1253: if (denyResult?.matches && denyResult.confidence === 'high') { 1254: return { 1255: behavior: 'deny', 1256: message: `Denied by Bash prompt rule: "${denyResult.matchedDescription}"`, 1257: decisionReason: { 1258: type: 'other', 1259: reason: `Denied by Bash prompt rule: "${denyResult.matchedDescription}"`, 1260: }, 1261: } 1262: } 1263: if (askResult?.matches && askResult.confidence === 'high') { 1264: let suggestions: PermissionUpdate[] 1265: if (getCommandSubcommandPrefixFn === getCommandSubcommandPrefix) { 1266: suggestions = suggestionForExactCommand(input.command) 1267: } else { 1268: const commandPrefixResult = await getCommandSubcommandPrefixFn( 1269: input.command, 1270: context.abortController.signal, 1271: context.options.isNonInteractiveSession, 1272: ) 1273: if (context.abortController.signal.aborted) { 1274: throw new AbortError() 1275: } 1276: suggestions = commandPrefixResult?.commandPrefix 1277: ? suggestionForPrefix(commandPrefixResult.commandPrefix) 1278: : suggestionForExactCommand(input.command) 1279: } 1280: return { 1281: behavior: 'ask', 1282: message: createPermissionRequestMessage(BashTool.name), 1283: decisionReason: { 1284: type: 'other', 1285: reason: `Required by Bash prompt rule: "${askResult.matchedDescription}"`, 1286: }, 1287: suggestions, 1288: ...(feature('BASH_CLASSIFIER') 1289: ? { 1290: pendingClassifierCheck: buildPendingClassifierCheck( 1291: input.command, 1292: appState.toolPermissionContext, 1293: ), 1294: } 1295: : {}), 1296: } 1297: } 1298: } 1299: } 1300: const commandOperatorResult = await checkCommandOperatorPermissions( 1301: input, 1302: (i: z.infer<typeof BashTool.inputSchema>) => 1303: bashToolHasPermission(i, context, getCommandSubcommandPrefixFn), 1304: { isNormalizedCdCommand, isNormalizedGitCommand }, 1305: astRoot, 1306: ) 1307: if (commandOperatorResult.behavior !== 'passthrough') { 1308: if (commandOperatorResult.behavior === 'allow') { 1309: const safetyResult = 1310: astSubcommands === null 1311: ? await bashCommandIsSafeAsync(input.command) 1312: : null 1313: if ( 1314: safetyResult !== null && 1315: safetyResult.behavior !== 'passthrough' && 1316: safetyResult.behavior !== 'allow' 1317: ) { 1318: appState = context.getAppState() 1319: return { 1320: behavior: 'ask', 1321: message: createPermissionRequestMessage(BashTool.name, { 1322: type: 'other', 1323: reason: 1324: safetyResult.message ?? 1325: 'Command contains patterns that require approval', 1326: }), 1327: decisionReason: { 1328: type: 'other', 1329: reason: 1330: safetyResult.message ?? 1331: 'Command contains patterns that require approval', 1332: }, 1333: ...(feature('BASH_CLASSIFIER') 1334: ? { 1335: pendingClassifierCheck: buildPendingClassifierCheck( 1336: input.command, 1337: appState.toolPermissionContext, 1338: ), 1339: } 1340: : {}), 1341: } 1342: } 1343: appState = context.getAppState() 1344: const pathResult = checkPathConstraints( 1345: input, 1346: getCwd(), 1347: appState.toolPermissionContext, 1348: commandHasAnyCd(input.command), 1349: astRedirects, 1350: astCommands, 1351: ) 1352: if (pathResult.behavior !== 'passthrough') { 1353: return pathResult 1354: } 1355: } 1356: if (commandOperatorResult.behavior === 'ask') { 1357: appState = context.getAppState() 1358: return { 1359: ...commandOperatorResult, 1360: ...(feature('BASH_CLASSIFIER') 1361: ? { 1362: pendingClassifierCheck: buildPendingClassifierCheck( 1363: input.command, 1364: appState.toolPermissionContext, 1365: ), 1366: } 1367: : {}), 1368: } 1369: } 1370: return commandOperatorResult 1371: } 1372: if ( 1373: astSubcommands === null && 1374: !isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_COMMAND_INJECTION_CHECK) 1375: ) { 1376: const originalCommandSafetyResult = await bashCommandIsSafeAsync( 1377: input.command, 1378: ) 1379: if ( 1380: originalCommandSafetyResult.behavior === 'ask' && 1381: originalCommandSafetyResult.isBashSecurityCheckForMisparsing 1382: ) { 1383: const remainder = stripSafeHeredocSubstitutions(input.command) 1384: const remainderResult = 1385: remainder !== null ? await bashCommandIsSafeAsync(remainder) : null 1386: if ( 1387: remainder === null || 1388: (remainderResult?.behavior === 'ask' && 1389: remainderResult.isBashSecurityCheckForMisparsing) 1390: ) { 1391: appState = context.getAppState() 1392: const exactMatchResult = bashToolCheckExactMatchPermission( 1393: input, 1394: appState.toolPermissionContext, 1395: ) 1396: if (exactMatchResult.behavior === 'allow') { 1397: return exactMatchResult 1398: } 1399: const decisionReason: PermissionDecisionReason = { 1400: type: 'other' as const, 1401: reason: originalCommandSafetyResult.message, 1402: } 1403: return { 1404: behavior: 'ask', 1405: message: createPermissionRequestMessage( 1406: BashTool.name, 1407: decisionReason, 1408: ), 1409: decisionReason, 1410: suggestions: [], 1411: ...(feature('BASH_CLASSIFIER') 1412: ? { 1413: pendingClassifierCheck: buildPendingClassifierCheck( 1414: input.command, 1415: appState.toolPermissionContext, 1416: ), 1417: } 1418: : {}), 1419: } 1420: } 1421: } 1422: } 1423: const cwd = getCwd() 1424: const cwdMingw = 1425: getPlatform() === 'windows' ? windowsPathToPosixPath(cwd) : cwd 1426: const rawSubcommands = 1427: astSubcommands ?? shadowLegacySubs ?? splitCommand(input.command) 1428: const { subcommands, astCommandsByIdx } = filterCdCwdSubcommands( 1429: rawSubcommands, 1430: astCommands, 1431: cwd, 1432: cwdMingw, 1433: ) 1434: if ( 1435: astSubcommands === null && 1436: subcommands.length > MAX_SUBCOMMANDS_FOR_SECURITY_CHECK 1437: ) { 1438: logForDebugging( 1439: `bashPermissions: ${subcommands.length} subcommands exceeds cap (${MAX_SUBCOMMANDS_FOR_SECURITY_CHECK}) — returning ask`, 1440: { level: 'debug' }, 1441: ) 1442: const decisionReason = { 1443: type: 'other' as const, 1444: reason: `Command splits into ${subcommands.length} subcommands, too many to safety-check individually`, 1445: } 1446: return { 1447: behavior: 'ask', 1448: message: createPermissionRequestMessage(BashTool.name, decisionReason), 1449: decisionReason, 1450: } 1451: } 1452: const cdCommands = subcommands.filter(subCommand => 1453: isNormalizedCdCommand(subCommand), 1454: ) 1455: if (cdCommands.length > 1) { 1456: const decisionReason = { 1457: type: 'other' as const, 1458: reason: 1459: 'Multiple directory changes in one command require approval for clarity', 1460: } 1461: return { 1462: behavior: 'ask', 1463: decisionReason, 1464: message: createPermissionRequestMessage(BashTool.name, decisionReason), 1465: } 1466: } 1467: const compoundCommandHasCd = cdCommands.length > 0 1468: if (compoundCommandHasCd) { 1469: const hasGitCommand = subcommands.some(cmd => 1470: isNormalizedGitCommand(cmd.trim()), 1471: ) 1472: if (hasGitCommand) { 1473: const decisionReason = { 1474: type: 'other' as const, 1475: reason: 1476: 'Compound commands with cd and git require approval to prevent bare repository attacks', 1477: } 1478: return { 1479: behavior: 'ask', 1480: decisionReason, 1481: message: createPermissionRequestMessage(BashTool.name, decisionReason), 1482: } 1483: } 1484: } 1485: appState = context.getAppState() 1486: const subcommandPermissionDecisions = subcommands.map((command, i) => 1487: bashToolCheckPermission( 1488: { command }, 1489: appState.toolPermissionContext, 1490: compoundCommandHasCd, 1491: astCommandsByIdx[i], 1492: ), 1493: ) 1494: const deniedSubresult = subcommandPermissionDecisions.find( 1495: _ => _.behavior === 'deny', 1496: ) 1497: if (deniedSubresult !== undefined) { 1498: return { 1499: behavior: 'deny', 1500: message: `Permission to use ${BashTool.name} with command ${input.command} has been denied.`, 1501: decisionReason: { 1502: type: 'subcommandResults', 1503: reasons: new Map( 1504: subcommandPermissionDecisions.map((result, i) => [ 1505: subcommands[i]!, 1506: result, 1507: ]), 1508: ), 1509: }, 1510: } 1511: } 1512: const pathResult = checkPathConstraints( 1513: input, 1514: getCwd(), 1515: appState.toolPermissionContext, 1516: compoundCommandHasCd, 1517: astRedirects, 1518: astCommands, 1519: ) 1520: if (pathResult.behavior === 'deny') { 1521: return pathResult 1522: } 1523: const askSubresult = subcommandPermissionDecisions.find( 1524: _ => _.behavior === 'ask', 1525: ) 1526: const nonAllowCount = count( 1527: subcommandPermissionDecisions, 1528: _ => _.behavior !== 'allow', 1529: ) 1530: if (pathResult.behavior === 'ask' && askSubresult === undefined) { 1531: return pathResult 1532: } 1533: if (askSubresult !== undefined && nonAllowCount === 1) { 1534: return { 1535: ...askSubresult, 1536: ...(feature('BASH_CLASSIFIER') 1537: ? { 1538: pendingClassifierCheck: buildPendingClassifierCheck( 1539: input.command, 1540: appState.toolPermissionContext, 1541: ), 1542: } 1543: : {}), 1544: } 1545: } 1546: if (exactMatchResult.behavior === 'allow') { 1547: return exactMatchResult 1548: } 1549: let hasPossibleCommandInjection = false 1550: if ( 1551: astSubcommands === null && 1552: !isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_COMMAND_INJECTION_CHECK) 1553: ) { 1554: let divergenceCount = 0 1555: const onDivergence = () => { 1556: divergenceCount++ 1557: } 1558: const results = await Promise.all( 1559: subcommands.map(c => bashCommandIsSafeAsync(c, onDivergence)), 1560: ) 1561: hasPossibleCommandInjection = results.some( 1562: r => r.behavior !== 'passthrough', 1563: ) 1564: if (divergenceCount > 0) { 1565: logEvent('tengu_tree_sitter_security_divergence', { 1566: quoteContextDivergence: true, 1567: count: divergenceCount, 1568: }) 1569: } 1570: } 1571: if ( 1572: subcommandPermissionDecisions.every(_ => _.behavior === 'allow') && 1573: !hasPossibleCommandInjection 1574: ) { 1575: return { 1576: behavior: 'allow', 1577: updatedInput: input, 1578: decisionReason: { 1579: type: 'subcommandResults', 1580: reasons: new Map( 1581: subcommandPermissionDecisions.map((result, i) => [ 1582: subcommands[i]!, 1583: result, 1584: ]), 1585: ), 1586: }, 1587: } 1588: } 1589: let commandSubcommandPrefix: Awaited< 1590: ReturnType<typeof getCommandSubcommandPrefixFn> 1591: > = null 1592: if (getCommandSubcommandPrefixFn !== getCommandSubcommandPrefix) { 1593: commandSubcommandPrefix = await getCommandSubcommandPrefixFn( 1594: input.command, 1595: context.abortController.signal, 1596: context.options.isNonInteractiveSession, 1597: ) 1598: if (context.abortController.signal.aborted) { 1599: throw new AbortError() 1600: } 1601: } 1602: appState = context.getAppState() 1603: if (subcommands.length === 1) { 1604: const result = await checkCommandAndSuggestRules( 1605: { command: subcommands[0]! }, 1606: appState.toolPermissionContext, 1607: commandSubcommandPrefix, 1608: compoundCommandHasCd, 1609: astSubcommands !== null, 1610: ) 1611: if (result.behavior === 'ask' || result.behavior === 'passthrough') { 1612: return { 1613: ...result, 1614: ...(feature('BASH_CLASSIFIER') 1615: ? { 1616: pendingClassifierCheck: buildPendingClassifierCheck( 1617: input.command, 1618: appState.toolPermissionContext, 1619: ), 1620: } 1621: : {}), 1622: } 1623: } 1624: return result 1625: } 1626: const subcommandResults: Map<string, PermissionResult> = new Map() 1627: for (const subcommand of subcommands) { 1628: subcommandResults.set( 1629: subcommand, 1630: await checkCommandAndSuggestRules( 1631: { 1632: ...input, 1633: command: subcommand, 1634: }, 1635: appState.toolPermissionContext, 1636: commandSubcommandPrefix?.subcommandPrefixes.get(subcommand), 1637: compoundCommandHasCd, 1638: astSubcommands !== null, 1639: ), 1640: ) 1641: } 1642: if ( 1643: subcommands.every(subcommand => { 1644: const permissionResult = subcommandResults.get(subcommand) 1645: return permissionResult?.behavior === 'allow' 1646: }) 1647: ) { 1648: return { 1649: behavior: 'allow', 1650: updatedInput: input, 1651: decisionReason: { 1652: type: 'subcommandResults', 1653: reasons: subcommandResults, 1654: }, 1655: } 1656: } 1657: const collectedRules: Map<string, PermissionRuleValue> = new Map() 1658: for (const [subcommand, permissionResult] of subcommandResults) { 1659: if ( 1660: permissionResult.behavior === 'ask' || 1661: permissionResult.behavior === 'passthrough' 1662: ) { 1663: const updates = 1664: 'suggestions' in permissionResult 1665: ? permissionResult.suggestions 1666: : undefined 1667: const rules = extractRules(updates) 1668: for (const rule of rules) { 1669: const ruleKey = permissionRuleValueToString(rule) 1670: collectedRules.set(ruleKey, rule) 1671: } 1672: if ( 1673: permissionResult.behavior === 'ask' && 1674: rules.length === 0 && 1675: permissionResult.decisionReason?.type !== 'rule' 1676: ) { 1677: for (const rule of extractRules( 1678: suggestionForExactCommand(subcommand), 1679: )) { 1680: const ruleKey = permissionRuleValueToString(rule) 1681: collectedRules.set(ruleKey, rule) 1682: } 1683: } 1684: } 1685: } 1686: const decisionReason = { 1687: type: 'subcommandResults' as const, 1688: reasons: subcommandResults, 1689: } 1690: const cappedRules = Array.from(collectedRules.values()).slice( 1691: 0, 1692: MAX_SUGGESTED_RULES_FOR_COMPOUND, 1693: ) 1694: const suggestedUpdates: PermissionUpdate[] | undefined = 1695: cappedRules.length > 0 1696: ? [ 1697: { 1698: type: 'addRules', 1699: rules: cappedRules, 1700: behavior: 'allow', 1701: destination: 'localSettings', 1702: }, 1703: ] 1704: : undefined 1705: return { 1706: behavior: askSubresult !== undefined ? 'ask' : 'passthrough', 1707: message: createPermissionRequestMessage(BashTool.name, decisionReason), 1708: decisionReason, 1709: suggestions: suggestedUpdates, 1710: ...(feature('BASH_CLASSIFIER') 1711: ? { 1712: pendingClassifierCheck: buildPendingClassifierCheck( 1713: input.command, 1714: appState.toolPermissionContext, 1715: ), 1716: } 1717: : {}), 1718: } 1719: } 1720: export function isNormalizedGitCommand(command: string): boolean { 1721: if (command.startsWith('git ') || command === 'git') { 1722: return true 1723: } 1724: const stripped = stripSafeWrappers(command) 1725: const parsed = tryParseShellCommand(stripped) 1726: if (parsed.success && parsed.tokens.length > 0) { 1727: if (parsed.tokens[0] === 'git') { 1728: return true 1729: } 1730: if (parsed.tokens[0] === 'xargs' && parsed.tokens.includes('git')) { 1731: return true 1732: } 1733: return false 1734: } 1735: return /^git(?:\s|$)/.test(stripped) 1736: } 1737: export function isNormalizedCdCommand(command: string): boolean { 1738: const stripped = stripSafeWrappers(command) 1739: const parsed = tryParseShellCommand(stripped) 1740: if (parsed.success && parsed.tokens.length > 0) { 1741: const cmd = parsed.tokens[0] 1742: return cmd === 'cd' || cmd === 'pushd' || cmd === 'popd' 1743: } 1744: return /^(?:cd|pushd|popd)(?:\s|$)/.test(stripped) 1745: } 1746: export function commandHasAnyCd(command: string): boolean { 1747: return splitCommand(command).some(subcmd => 1748: isNormalizedCdCommand(subcmd.trim()), 1749: ) 1750: }

File: src/tools/BashTool/bashSecurity.ts

typescript 1: import { logEvent } from 'src/services/analytics/index.js' 2: import { extractHeredocs } from '../../utils/bash/heredoc.js' 3: import { ParsedCommand } from '../../utils/bash/ParsedCommand.js' 4: import { 5: hasMalformedTokens, 6: hasShellQuoteSingleQuoteBug, 7: tryParseShellCommand, 8: } from '../../utils/bash/shellQuote.js' 9: import type { TreeSitterAnalysis } from '../../utils/bash/treeSitterAnalysis.js' 10: import type { PermissionResult } from '../../utils/permissions/PermissionResult.js' 11: const HEREDOC_IN_SUBSTITUTION = /\$\(.*<</ 12: const COMMAND_SUBSTITUTION_PATTERNS = [ 13: { pattern: /<\(/, message: 'process substitution <()' }, 14: { pattern: />\(/, message: 'process substitution >()' }, 15: { pattern: /=\(/, message: 'Zsh process substitution =()' }, 16: { 17: pattern: /(?:^|[\s;&|])=[a-zA-Z_]/, 18: message: 'Zsh equals expansion (=cmd)', 19: }, 20: { pattern: /\$\(/, message: '$() command substitution' }, 21: { pattern: /\$\{/, message: '${} parameter substitution' }, 22: { pattern: /\$\[/, message: '$[] legacy arithmetic expansion' }, 23: { pattern: /~\[/, message: 'Zsh-style parameter expansion' }, 24: { pattern: /\(e:/, message: 'Zsh-style glob qualifiers' }, 25: { pattern: /\(\+/, message: 'Zsh glob qualifier with command execution' }, 26: { 27: pattern: /\}\s*always\s*\{/, 28: message: 'Zsh always block (try/always construct)', 29: }, 30: { pattern: /<#/, message: 'PowerShell comment syntax' }, 31: ] 32: const ZSH_DANGEROUS_COMMANDS = new Set([ 33: 'zmodload', 34: 'emulate', 35: 'sysopen', 36: 'sysread', 37: 'syswrite', 38: 'sysseek', 39: 'zpty', 40: 'ztcp', 41: 'zsocket', 42: 'mapfile', 43: 'zf_rm', 44: 'zf_mv', 45: 'zf_ln', 46: 'zf_chmod', 47: 'zf_chown', 48: 'zf_mkdir', 49: 'zf_rmdir', 50: 'zf_chgrp', 51: ]) 52: const BASH_SECURITY_CHECK_IDS = { 53: INCOMPLETE_COMMANDS: 1, 54: JQ_SYSTEM_FUNCTION: 2, 55: JQ_FILE_ARGUMENTS: 3, 56: OBFUSCATED_FLAGS: 4, 57: SHELL_METACHARACTERS: 5, 58: DANGEROUS_VARIABLES: 6, 59: NEWLINES: 7, 60: DANGEROUS_PATTERNS_COMMAND_SUBSTITUTION: 8, 61: DANGEROUS_PATTERNS_INPUT_REDIRECTION: 9, 62: DANGEROUS_PATTERNS_OUTPUT_REDIRECTION: 10, 63: IFS_INJECTION: 11, 64: GIT_COMMIT_SUBSTITUTION: 12, 65: PROC_ENVIRON_ACCESS: 13, 66: MALFORMED_TOKEN_INJECTION: 14, 67: BACKSLASH_ESCAPED_WHITESPACE: 15, 68: BRACE_EXPANSION: 16, 69: CONTROL_CHARACTERS: 17, 70: UNICODE_WHITESPACE: 18, 71: MID_WORD_HASH: 19, 72: ZSH_DANGEROUS_COMMANDS: 20, 73: BACKSLASH_ESCAPED_OPERATORS: 21, 74: COMMENT_QUOTE_DESYNC: 22, 75: QUOTED_NEWLINE: 23, 76: } as const 77: type ValidationContext = { 78: originalCommand: string 79: baseCommand: string 80: unquotedContent: string 81: fullyUnquotedContent: string 82: fullyUnquotedPreStrip: string 83: unquotedKeepQuoteChars: string 84: treeSitter?: TreeSitterAnalysis | null 85: } 86: type QuoteExtraction = { 87: withDoubleQuotes: string 88: fullyUnquoted: string 89: unquotedKeepQuoteChars: string 90: } 91: function extractQuotedContent(command: string, isJq = false): QuoteExtraction { 92: let withDoubleQuotes = '' 93: let fullyUnquoted = '' 94: let unquotedKeepQuoteChars = '' 95: let inSingleQuote = false 96: let inDoubleQuote = false 97: let escaped = false 98: for (let i = 0; i < command.length; i++) { 99: const char = command[i] 100: if (escaped) { 101: escaped = false 102: if (!inSingleQuote) withDoubleQuotes += char 103: if (!inSingleQuote && !inDoubleQuote) fullyUnquoted += char 104: if (!inSingleQuote && !inDoubleQuote) unquotedKeepQuoteChars += char 105: continue 106: } 107: if (char === '\\' && !inSingleQuote) { 108: escaped = true 109: if (!inSingleQuote) withDoubleQuotes += char 110: if (!inSingleQuote && !inDoubleQuote) fullyUnquoted += char 111: if (!inSingleQuote && !inDoubleQuote) unquotedKeepQuoteChars += char 112: continue 113: } 114: if (char === "'" && !inDoubleQuote) { 115: inSingleQuote = !inSingleQuote 116: unquotedKeepQuoteChars += char 117: continue 118: } 119: if (char === '"' && !inSingleQuote) { 120: inDoubleQuote = !inDoubleQuote 121: unquotedKeepQuoteChars += char 122: // For jq, include quotes in extraction to ensure content is properly analyzed 123: if (!isJq) continue 124: } 125: if (!inSingleQuote) withDoubleQuotes += char 126: if (!inSingleQuote && !inDoubleQuote) fullyUnquoted += char 127: if (!inSingleQuote && !inDoubleQuote) unquotedKeepQuoteChars += char 128: } 129: return { withDoubleQuotes, fullyUnquoted, unquotedKeepQuoteChars } 130: } 131: function stripSafeRedirections(content: string): string { 132: // SECURITY: All three patterns MUST have a trailing boundary (?=\s|$). 133: // Without it, `> /dev/nullo` matches `/dev/null` as a PREFIX, strips 134: // `> /dev/null` leaving `o`, so `echo hi > /dev/nullo` becomes `echo hi o`. 135: // validateRedirections then sees no `>` and passes. The file write to 136: // /dev/nullo is auto-allowed via the read-only path (checkReadOnlyConstraints). 137: // Main bashPermissions flow is protected (checkPathConstraints validates the 138: // original command), but speculation.ts uses checkReadOnlyConstraints alone. 139: return content 140: .replace(/\s+2\s*>&\s*1(?=\s|$)/g, '') 141: .replace(/[012]?\s*>\s*\/dev\/null(?=\s|$)/g, '') 142: .replace(/\s*<\s*\/dev\/null(?=\s|$)/g, '') 143: } 144: /** 145: * Checks if content contains an unescaped occurrence of a single character. 146: * Handles bash escape sequences correctly where a backslash escapes the following character. 147: * 148: * IMPORTANT: This function only handles single characters, not strings. If you need to extend 149: * this to handle multi-character strings, be EXTREMELY CAREFUL about shell ANSI-C quoting 150: * (e.g., $'\n', $'\x41', $'\u0041') which can encode arbitrary characters and strings in ways 151: * that are very difficult to parse correctly. Incorrect handling could introduce security 152: * vulnerabilities by allowing attackers to bypass security checks. 153: * 154: * @param content - The string to search (typically from extractQuotedContent) 155: * @param char - Single character to search for (e.g., '`') 156: * @returns true if unescaped occurrence found, false otherwise 157: * 158: * Examples: 159: * hasUnescapedChar("test \`safe\`", '`') → false (escaped backticks) 160: * hasUnescapedChar("test `dangerous`", '`') → true (unescaped backticks) 161: * hasUnescapedChar("test\\`date`", '`') → true (escaped backslash + unescaped backtick) 162: */ 163: function hasUnescapedChar(content: string, char: string): boolean { 164: if (char.length !== 1) { 165: throw new Error('hasUnescapedChar only works with single characters') 166: } 167: let i = 0 168: while (i < content.length) { 169: if (content[i] === '\\' && i + 1 < content.length) { 170: i += 2 // Skip backslash and escaped character 171: continue 172: } 173: // Check if current character matches 174: if (content[i] === char) { 175: return true // Found unescaped occurrence 176: } 177: i++ 178: } 179: return false // No unescaped occurrences found 180: } 181: function validateEmpty(context: ValidationContext): PermissionResult { 182: if (!context.originalCommand.trim()) { 183: return { 184: behavior: 'allow', 185: updatedInput: { command: context.originalCommand }, 186: decisionReason: { type: 'other', reason: 'Empty command is safe' }, 187: } 188: } 189: return { behavior: 'passthrough', message: 'Command is not empty' } 190: } 191: function validateIncompleteCommands( 192: context: ValidationContext, 193: ): PermissionResult { 194: const { originalCommand } = context 195: const trimmed = originalCommand.trim() 196: if (/^\s*\t/.test(originalCommand)) { 197: logEvent('tengu_bash_security_check_triggered', { 198: checkId: BASH_SECURITY_CHECK_IDS.INCOMPLETE_COMMANDS, 199: subId: 1, 200: }) 201: return { 202: behavior: 'ask', 203: message: 'Command appears to be an incomplete fragment (starts with tab)', 204: } 205: } 206: if (trimmed.startsWith('-')) { 207: logEvent('tengu_bash_security_check_triggered', { 208: checkId: BASH_SECURITY_CHECK_IDS.INCOMPLETE_COMMANDS, 209: subId: 2, 210: }) 211: return { 212: behavior: 'ask', 213: message: 214: 'Command appears to be an incomplete fragment (starts with flags)', 215: } 216: } 217: if (/^\s*(&&|\|\||;|>>?|<)/.test(originalCommand)) { 218: logEvent('tengu_bash_security_check_triggered', { 219: checkId: BASH_SECURITY_CHECK_IDS.INCOMPLETE_COMMANDS, 220: subId: 3, 221: }) 222: return { 223: behavior: 'ask', 224: message: 225: 'Command appears to be a continuation line (starts with operator)', 226: } 227: } 228: return { behavior: 'passthrough', message: 'Command appears complete' } 229: } 230: function isSafeHeredoc(command: string): boolean { 231: if (!HEREDOC_IN_SUBSTITUTION.test(command)) return false 232: const heredocPattern = 233: /\$\(cat[ \t]*<<(-?)[ \t]*(?:'+([A-Za-z_]\w*)'+|\\([A-Za-z_]\w*))/g 234: let match 235: type HeredocMatch = { 236: start: number 237: operatorEnd: number 238: delimiter: string 239: isDash: boolean 240: } 241: const safeHeredocs: HeredocMatch[] = [] 242: while ((match = heredocPattern.exec(command)) !== null) { 243: const delimiter = match[2] || match[3] 244: if (delimiter) { 245: safeHeredocs.push({ 246: start: match.index, 247: operatorEnd: match.index + match[0].length, 248: delimiter, 249: isDash: match[1] === '-', 250: }) 251: } 252: } 253: if (safeHeredocs.length === 0) return false 254: type VerifiedHeredoc = { start: number; end: number } 255: const verified: VerifiedHeredoc[] = [] 256: for (const { start, operatorEnd, delimiter, isDash } of safeHeredocs) { 257: const afterOperator = command.slice(operatorEnd) 258: const openLineEnd = afterOperator.indexOf('\n') 259: if (openLineEnd === -1) return false 260: const openLineTail = afterOperator.slice(0, openLineEnd) 261: if (!/^[ \t]*$/.test(openLineTail)) return false 262: const bodyStart = operatorEnd + openLineEnd + 1 263: const body = command.slice(bodyStart) 264: const bodyLines = body.split('\n') 265: let closingLineIdx = -1 266: let closeParenLineIdx = -1 267: let closeParenColIdx = -1 268: for (let i = 0; i < bodyLines.length; i++) { 269: const rawLine = bodyLines[i]! 270: const line = isDash ? rawLine.replace(/^\t*/, '') : rawLine 271: // Form 1: delimiter alone on a line 272: if (line === delimiter) { 273: closingLineIdx = i 274: // The `)` must be on the NEXT line with only whitespace before it 275: const nextLine = bodyLines[i + 1] 276: if (nextLine === undefined) return false // No closing `)` 277: const parenMatch = nextLine.match(/^([ \t]*)\)/) 278: if (!parenMatch) return false // `)` not at start of next line 279: closeParenLineIdx = i + 1 280: closeParenColIdx = parenMatch[1]!.length // Position of `)` 281: break 282: } 283: // Form 2: delimiter immediately followed by `)` (PST_EOFTOKEN form) 284: // Only whitespace allowed between delimiter and `)`. 285: if (line.startsWith(delimiter)) { 286: const afterDelim = line.slice(delimiter.length) 287: const parenMatch = afterDelim.match(/^([ \t]*)\)/) 288: if (parenMatch) { 289: closingLineIdx = i 290: closeParenLineIdx = i 291: // Column is in rawLine (pre-tab-strip), so recompute 292: const tabPrefix = isDash ? (rawLine.match(/^\t*/)?.[0] ?? '') : '' 293: closeParenColIdx = 294: tabPrefix.length + delimiter.length + parenMatch[1]!.length 295: break 296: } 297: // Line starts with delimiter but has other trailing content — 298: // this is NOT the closing line (bash requires exact match or EOF`)`). 299: // But it's also a red flag: if this were inside $(), bash might 300: if (/^[)}`|&;(<>]/.test(afterDelim)) { 301: return false // Ambiguous early-closure pattern 302: } 303: } 304: } 305: if (closingLineIdx === -1) return false // No closing delimiter found 306: // Compute the absolute end position (one past the `)` character) 307: let endPos = bodyStart 308: for (let i = 0; i < closeParenLineIdx; i++) { 309: endPos += bodyLines[i]!.length + 1 // +1 for newline 310: } 311: endPos += closeParenColIdx + 1 // +1 to include the `)` itself 312: verified.push({ start, end: endPos }) 313: } 314: // SECURITY: Reject nested matches. The regex finds $(cat <<'X' patterns 315: // in RAW TEXT without understanding quoted-heredoc semantics. When the 316: // outer heredoc has a quoted delimiter (<<'A'), its body is LITERAL text 317: // in bash — any inner $(cat <<'B' is just characters, not a real heredoc. 318: // But our regex matches both, producing NESTED ranges. Stripping nested 319: // ranges corrupts indices: after stripping the inner range, the outer 320: // range's `end` is stale (points past the shrunken string), causing 321: for (const outer of verified) { 322: for (const inner of verified) { 323: if (inner === outer) continue 324: if (inner.start > outer.start && inner.start < outer.end) { 325: return false 326: } 327: } 328: } 329: const sortedVerified = [...verified].sort((a, b) => b.start - a.start) 330: let remaining = command 331: for (const { start, end } of sortedVerified) { 332: remaining = remaining.slice(0, start) + remaining.slice(end) 333: } 334: const trimmedRemaining = remaining.trim() 335: if (trimmedRemaining.length > 0) { 336: const firstHeredocStart = Math.min(...verified.map(v => v.start)) 337: const prefix = command.slice(0, firstHeredocStart) 338: if (prefix.trim().length === 0) { 339: return false 340: } 341: } 342: if (!/^[a-zA-Z0-9 \t"'.\-/_@=,:+~]*$/.test(remaining)) return false 343: // SECURITY: The remaining text (command with heredocs stripped) must also 344: // pass all security validators. Without this, appending a safe heredoc to a 345: // dangerous command (e.g., `zmodload zsh/system $(cat <<'EOF'\nx\nEOF\n)`) 346: // causes this early-allow path to return passthrough, bypassing 347: // validateZshDangerousCommands, validateProcEnvironAccess, and any other 348: // main validator that checks allowlist-safe character patterns. 349: // No recursion risk: `remaining` has no `$(... <<` pattern, so the recursive 350: if (bashCommandIsSafe_DEPRECATED(remaining).behavior !== 'passthrough') 351: return false 352: return true 353: } 354: export function stripSafeHeredocSubstitutions(command: string): string | null { 355: if (!HEREDOC_IN_SUBSTITUTION.test(command)) return null 356: const heredocPattern = 357: /\$\(cat[ \t]*<<(-?)[ \t]*(?:'+([A-Za-z_]\w*)'+|\\([A-Za-z_]\w*))/g 358: let result = command 359: let found = false 360: let match 361: const ranges: Array<{ start: number; end: number }> = [] 362: while ((match = heredocPattern.exec(command)) !== null) { 363: if (match.index > 0 && command[match.index - 1] === '\\') continue 364: const delimiter = match[2] || match[3] 365: if (!delimiter) continue 366: const isDash = match[1] === '-' 367: const operatorEnd = match.index + match[0].length 368: const afterOperator = command.slice(operatorEnd) 369: const openLineEnd = afterOperator.indexOf('\n') 370: if (openLineEnd === -1) continue 371: if (!/^[ \t]*$/.test(afterOperator.slice(0, openLineEnd))) continue 372: const bodyStart = operatorEnd + openLineEnd + 1 373: const bodyLines = command.slice(bodyStart).split('\n') 374: for (let i = 0; i < bodyLines.length; i++) { 375: const rawLine = bodyLines[i]! 376: const line = isDash ? rawLine.replace(/^\t*/, '') : rawLine 377: if (line.startsWith(delimiter)) { 378: const after = line.slice(delimiter.length) 379: let closePos = -1 380: if (/^[ \t]*\)/.test(after)) { 381: const lineStart = 382: bodyStart + 383: bodyLines.slice(0, i).join('\n').length + 384: (i > 0 ? 1 : 0) 385: closePos = command.indexOf(')', lineStart) 386: } else if (after === '') { 387: const nextLine = bodyLines[i + 1] 388: if (nextLine !== undefined && /^[ \t]*\)/.test(nextLine)) { 389: const nextLineStart = 390: bodyStart + bodyLines.slice(0, i + 1).join('\n').length + 1 391: closePos = command.indexOf(')', nextLineStart) 392: } 393: } 394: if (closePos !== -1) { 395: ranges.push({ start: match.index, end: closePos + 1 }) 396: found = true 397: } 398: break 399: } 400: } 401: } 402: if (!found) return null 403: for (let i = ranges.length - 1; i >= 0; i--) { 404: const r = ranges[i]! 405: result = result.slice(0, r.start) + result.slice(r.end) 406: } 407: return result 408: } 409: export function hasSafeHeredocSubstitution(command: string): boolean { 410: return stripSafeHeredocSubstitutions(command) !== null 411: } 412: function validateSafeCommandSubstitution( 413: context: ValidationContext, 414: ): PermissionResult { 415: const { originalCommand } = context 416: if (!HEREDOC_IN_SUBSTITUTION.test(originalCommand)) { 417: return { behavior: 'passthrough', message: 'No heredoc in substitution' } 418: } 419: if (isSafeHeredoc(originalCommand)) { 420: return { 421: behavior: 'allow', 422: updatedInput: { command: originalCommand }, 423: decisionReason: { 424: type: 'other', 425: reason: 426: 'Safe command substitution: cat with quoted/escaped heredoc delimiter', 427: }, 428: } 429: } 430: return { 431: behavior: 'passthrough', 432: message: 'Command substitution needs validation', 433: } 434: } 435: function validateGitCommit(context: ValidationContext): PermissionResult { 436: const { originalCommand, baseCommand } = context 437: if (baseCommand !== 'git' || !/^git\s+commit\s+/.test(originalCommand)) { 438: return { behavior: 'passthrough', message: 'Not a git commit' } 439: } 440: if (originalCommand.includes('\\')) { 441: return { 442: behavior: 'passthrough', 443: message: 'Git commit contains backslash, needs full validation', 444: } 445: } 446: const messageMatch = originalCommand.match( 447: /^git[ \t]+commit[ \t]+[^;&|`$<>()\n\r]*?-m[ \t]+(["'])([\s\S]*?)\1(.*)$/, 448: ) 449: if (messageMatch) { 450: const [, quote, messageContent, remainder] = messageMatch 451: if (quote === '"' && messageContent && /\$\(|`|\$\{/.test(messageContent)) { 452: logEvent('tengu_bash_security_check_triggered', { 453: checkId: BASH_SECURITY_CHECK_IDS.GIT_COMMIT_SUBSTITUTION, 454: subId: 1, 455: }) 456: return { 457: behavior: 'ask', 458: message: 'Git commit message contains command substitution patterns', 459: } 460: } 461: if (remainder && /[;|&()`]|\$\(|\$\{/.test(remainder)) { 462: return { 463: behavior: 'passthrough', 464: message: 'Git commit remainder contains shell metacharacters', 465: } 466: } 467: if (remainder) { 468: // Strip quoted content, then check for `<` or `>`. Quoted `<>` (email 469: // brackets in --author) are safe; unquoted `<>` are shell redirects. 470: // NOTE: This simple quote tracker has NO backslash handling. `\'`/`\"` 471: // outside quotes would desync it (bash: \' = literal ', tracker: toggles 472: // SQ). BUT line 584 already bailed on ANY backslash in originalCommand, 473: // so we never reach here with backslashes. For backslash-free input, 474: // simple quote toggling is correct (no way to escape quotes without \\). 475: let unquoted = '' 476: let inSQ = false 477: let inDQ = false 478: for (let i = 0; i < remainder.length; i++) { 479: const c = remainder[i] 480: if (c === "'" && !inDQ) { 481: inSQ = !inSQ 482: continue 483: } 484: if (c === '"' && !inSQ) { 485: inDQ = !inDQ 486: continue 487: } 488: if (!inSQ && !inDQ) unquoted += c 489: } 490: if (/[<>]/.test(unquoted)) { 491: return { 492: behavior: 'passthrough', 493: message: 'Git commit remainder contains unquoted redirect operator', 494: } 495: } 496: } 497: // Security hardening: block messages starting with dash 498: // This catches potential obfuscation patterns like git commit -m "---" 499: if (messageContent && messageContent.startsWith('-')) { 500: logEvent('tengu_bash_security_check_triggered', { 501: checkId: BASH_SECURITY_CHECK_IDS.OBFUSCATED_FLAGS, 502: subId: 5, 503: }) 504: return { 505: behavior: 'ask', 506: message: 'Command contains quoted characters in flag names', 507: } 508: } 509: return { 510: behavior: 'allow', 511: updatedInput: { command: originalCommand }, 512: decisionReason: { 513: type: 'other', 514: reason: 'Git commit with simple quoted message is allowed', 515: }, 516: } 517: } 518: return { behavior: 'passthrough', message: 'Git commit needs validation' } 519: } 520: function validateJqCommand(context: ValidationContext): PermissionResult { 521: const { originalCommand, baseCommand } = context 522: if (baseCommand !== 'jq') { 523: return { behavior: 'passthrough', message: 'Not jq' } 524: } 525: if (/\bsystem\s*\(/.test(originalCommand)) { 526: logEvent('tengu_bash_security_check_triggered', { 527: checkId: BASH_SECURITY_CHECK_IDS.JQ_SYSTEM_FUNCTION, 528: subId: 1, 529: }) 530: return { 531: behavior: 'ask', 532: message: 533: 'jq command contains system() function which executes arbitrary commands', 534: } 535: } 536: // File arguments are now allowed - they will be validated by path validation in readOnlyValidation.ts 537: // Only block dangerous flags that could read files into jq variables 538: const afterJq = originalCommand.substring(3).trim() 539: if ( 540: /(?:^|\s)(?:-f\b|--from-file|--rawfile|--slurpfile|-L\b|--library-path)/.test( 541: afterJq, 542: ) 543: ) { 544: logEvent('tengu_bash_security_check_triggered', { 545: checkId: BASH_SECURITY_CHECK_IDS.JQ_FILE_ARGUMENTS, 546: subId: 1, 547: }) 548: return { 549: behavior: 'ask', 550: message: 551: 'jq command contains dangerous flags that could execute code or read arbitrary files', 552: } 553: } 554: return { behavior: 'passthrough', message: 'jq command is safe' } 555: } 556: function validateShellMetacharacters( 557: context: ValidationContext, 558: ): PermissionResult { 559: const { unquotedContent } = context 560: const message = 561: 'Command contains shell metacharacters (;, |, or &) in arguments' 562: if (/(?:^|\s)["'][^"']*[;&][^"']*["'](?:\s|$)/.test(unquotedContent)) { 563: logEvent('tengu_bash_security_check_triggered', { 564: checkId: BASH_SECURITY_CHECK_IDS.SHELL_METACHARACTERS, 565: subId: 1, 566: }) 567: return { behavior: 'ask', message } 568: } 569: const globPatterns = [ 570: /-name\s+["'][^"']*[;|&][^"']*["']/, 571: /-path\s+["'][^"']*[;|&][^"']*["']/, 572: /-iname\s+["'][^"']*[;|&][^"']*["']/, 573: ] 574: if (globPatterns.some(p => p.test(unquotedContent))) { 575: logEvent('tengu_bash_security_check_triggered', { 576: checkId: BASH_SECURITY_CHECK_IDS.SHELL_METACHARACTERS, 577: subId: 2, 578: }) 579: return { behavior: 'ask', message } 580: } 581: if (/-regex\s+["'][^"']*[;&][^"']*["']/.test(unquotedContent)) { 582: logEvent('tengu_bash_security_check_triggered', { 583: checkId: BASH_SECURITY_CHECK_IDS.SHELL_METACHARACTERS, 584: subId: 3, 585: }) 586: return { behavior: 'ask', message } 587: } 588: return { behavior: 'passthrough', message: 'No metacharacters' } 589: } 590: function validateDangerousVariables( 591: context: ValidationContext, 592: ): PermissionResult { 593: const { fullyUnquotedContent } = context 594: if ( 595: /[<>|]\s*\$[A-Za-z_]/.test(fullyUnquotedContent) || 596: /\$[A-Za-z_][A-Za-z0-9_]*\s*[|<>]/.test(fullyUnquotedContent) 597: ) { 598: logEvent('tengu_bash_security_check_triggered', { 599: checkId: BASH_SECURITY_CHECK_IDS.DANGEROUS_VARIABLES, 600: subId: 1, 601: }) 602: return { 603: behavior: 'ask', 604: message: 605: 'Command contains variables in dangerous contexts (redirections or pipes)', 606: } 607: } 608: return { behavior: 'passthrough', message: 'No dangerous variables' } 609: } 610: function validateDangerousPatterns( 611: context: ValidationContext, 612: ): PermissionResult { 613: const { unquotedContent } = context 614: // Special handling for backticks - check for UNESCAPED backticks only 615: // Escaped backticks (e.g., \`) are safe and commonly used in SQL commands 616: if (hasUnescapedChar(unquotedContent, '`')) { 617: return { 618: behavior: 'ask', 619: message: 'Command contains backticks (`) for command substitution', 620: } 621: } 622: for (const { pattern, message } of COMMAND_SUBSTITUTION_PATTERNS) { 623: if (pattern.test(unquotedContent)) { 624: logEvent('tengu_bash_security_check_triggered', { 625: checkId: 626: BASH_SECURITY_CHECK_IDS.DANGEROUS_PATTERNS_COMMAND_SUBSTITUTION, 627: subId: 1, 628: }) 629: return { behavior: 'ask', message: `Command contains ${message}` } 630: } 631: } 632: return { behavior: 'passthrough', message: 'No dangerous patterns' } 633: } 634: function validateRedirections(context: ValidationContext): PermissionResult { 635: const { fullyUnquotedContent } = context 636: if (/</.test(fullyUnquotedContent)) { 637: logEvent('tengu_bash_security_check_triggered', { 638: checkId: BASH_SECURITY_CHECK_IDS.DANGEROUS_PATTERNS_INPUT_REDIRECTION, 639: subId: 1, 640: }) 641: return { 642: behavior: 'ask', 643: message: 644: 'Command contains input redirection (<) which could read sensitive files', 645: } 646: } 647: if (/>/.test(fullyUnquotedContent)) { 648: logEvent('tengu_bash_security_check_triggered', { 649: checkId: BASH_SECURITY_CHECK_IDS.DANGEROUS_PATTERNS_OUTPUT_REDIRECTION, 650: subId: 1, 651: }) 652: return { 653: behavior: 'ask', 654: message: 655: 'Command contains output redirection (>) which could write to arbitrary files', 656: } 657: } 658: return { behavior: 'passthrough', message: 'No redirections' } 659: } 660: function validateNewlines(context: ValidationContext): PermissionResult { 661: const { fullyUnquotedPreStrip } = context 662: if (!/[\n\r]/.test(fullyUnquotedPreStrip)) { 663: return { behavior: 'passthrough', message: 'No newlines' } 664: } 665: const looksLikeCommand = /(?<![\s]\\)[\n\r]\s*\S/.test(fullyUnquotedPreStrip) 666: if (looksLikeCommand) { 667: logEvent('tengu_bash_security_check_triggered', { 668: checkId: BASH_SECURITY_CHECK_IDS.NEWLINES, 669: subId: 1, 670: }) 671: return { 672: behavior: 'ask', 673: message: 674: 'Command contains newlines that could separate multiple commands', 675: } 676: } 677: return { 678: behavior: 'passthrough', 679: message: 'Newlines appear to be within data', 680: } 681: } 682: function validateCarriageReturn(context: ValidationContext): PermissionResult { 683: const { originalCommand } = context 684: if (!originalCommand.includes('\r')) { 685: return { behavior: 'passthrough', message: 'No carriage return' } 686: } 687: let inSingleQuote = false 688: let inDoubleQuote = false 689: let escaped = false 690: for (let i = 0; i < originalCommand.length; i++) { 691: const c = originalCommand[i] 692: if (escaped) { 693: escaped = false 694: continue 695: } 696: if (c === '\\' && !inSingleQuote) { 697: escaped = true 698: continue 699: } 700: if (c === "'" && !inDoubleQuote) { 701: inSingleQuote = !inSingleQuote 702: continue 703: } 704: if (c === '"' && !inSingleQuote) { 705: inDoubleQuote = !inDoubleQuote 706: continue 707: } 708: if (c === '\r' && !inDoubleQuote) { 709: logEvent('tengu_bash_security_check_triggered', { 710: checkId: BASH_SECURITY_CHECK_IDS.NEWLINES, 711: subId: 2, 712: }) 713: return { 714: behavior: 'ask', 715: message: 716: 'Command contains carriage return (\\r) which shell-quote and bash tokenize differently', 717: } 718: } 719: } 720: return { behavior: 'passthrough', message: 'CR only inside double quotes' } 721: } 722: function validateIFSInjection(context: ValidationContext): PermissionResult { 723: const { originalCommand } = context 724: if (/\$IFS|\$\{[^}]*IFS/.test(originalCommand)) { 725: logEvent('tengu_bash_security_check_triggered', { 726: checkId: BASH_SECURITY_CHECK_IDS.IFS_INJECTION, 727: subId: 1, 728: }) 729: return { 730: behavior: 'ask', 731: message: 732: 'Command contains IFS variable usage which could bypass security validation', 733: } 734: } 735: return { behavior: 'passthrough', message: 'No IFS injection detected' } 736: } 737: function validateProcEnvironAccess( 738: context: ValidationContext, 739: ): PermissionResult { 740: const { originalCommand } = context 741: if (/\/proc\/.*\/environ/.test(originalCommand)) { 742: logEvent('tengu_bash_security_check_triggered', { 743: checkId: BASH_SECURITY_CHECK_IDS.PROC_ENVIRON_ACCESS, 744: subId: 1, 745: }) 746: return { 747: behavior: 'ask', 748: message: 749: 'Command accesses /proc/*/environ which could expose sensitive environment variables', 750: } 751: } 752: return { 753: behavior: 'passthrough', 754: message: 'No /proc/environ access detected', 755: } 756: } 757: function validateMalformedTokenInjection( 758: context: ValidationContext, 759: ): PermissionResult { 760: const { originalCommand } = context 761: const parseResult = tryParseShellCommand(originalCommand) 762: if (!parseResult.success) { 763: return { 764: behavior: 'passthrough', 765: message: 'Parse failed, handled elsewhere', 766: } 767: } 768: const parsed = parseResult.tokens 769: const hasCommandSeparator = parsed.some( 770: entry => 771: typeof entry === 'object' && 772: entry !== null && 773: 'op' in entry && 774: (entry.op === ';' || entry.op === '&&' || entry.op === '||'), 775: ) 776: if (!hasCommandSeparator) { 777: return { behavior: 'passthrough', message: 'No command separators' } 778: } 779: if (hasMalformedTokens(originalCommand, parsed)) { 780: logEvent('tengu_bash_security_check_triggered', { 781: checkId: BASH_SECURITY_CHECK_IDS.MALFORMED_TOKEN_INJECTION, 782: subId: 1, 783: }) 784: return { 785: behavior: 'ask', 786: message: 787: 'Command contains ambiguous syntax with command separators that could be misinterpreted', 788: } 789: } 790: return { 791: behavior: 'passthrough', 792: message: 'No malformed token injection detected', 793: } 794: } 795: function validateObfuscatedFlags(context: ValidationContext): PermissionResult { 796: const { originalCommand, baseCommand } = context 797: const hasShellOperators = /[|&;]/.test(originalCommand) 798: if (baseCommand === 'echo' && !hasShellOperators) { 799: return { 800: behavior: 'passthrough', 801: message: 'echo command is safe and has no dangerous flags', 802: } 803: } 804: if (/\$'[^']*'/.test(originalCommand)) { 805: logEvent('tengu_bash_security_check_triggered', { 806: checkId: BASH_SECURITY_CHECK_IDS.OBFUSCATED_FLAGS, 807: subId: 5, 808: }) 809: return { 810: behavior: 'ask', 811: message: 'Command contains ANSI-C quoting which can hide characters', 812: } 813: } 814: if (/\$"[^"]*"/.test(originalCommand)) { 815: logEvent('tengu_bash_security_check_triggered', { 816: checkId: BASH_SECURITY_CHECK_IDS.OBFUSCATED_FLAGS, 817: subId: 6, 818: }) 819: return { 820: behavior: 'ask', 821: message: 'Command contains locale quoting which can hide characters', 822: } 823: } 824: // 3. Block empty ANSI-C or locale quotes followed by dash 825: // $''-exec or $""-exec 826: if (/\$['"]{2}\s*-/.test(originalCommand)) { 827: logEvent('tengu_bash_security_check_triggered', { 828: checkId: BASH_SECURITY_CHECK_IDS.OBFUSCATED_FLAGS, 829: subId: 9, 830: }) 831: return { 832: behavior: 'ask', 833: message: 834: 'Command contains empty special quotes before dash (potential bypass)', 835: } 836: } 837: if (/(?:^|\s)(?:''|"")+\s*-/.test(originalCommand)) { 838: logEvent('tengu_bash_security_check_triggered', { 839: checkId: BASH_SECURITY_CHECK_IDS.OBFUSCATED_FLAGS, 840: subId: 7, 841: }) 842: return { 843: behavior: 'ask', 844: message: 'Command contains empty quotes before dash (potential bypass)', 845: } 846: } 847: if (/(?:""|'')+['"]-/.test(originalCommand)) { 848: logEvent('tengu_bash_security_check_triggered', { 849: checkId: BASH_SECURITY_CHECK_IDS.OBFUSCATED_FLAGS, 850: subId: 10, 851: }) 852: return { 853: behavior: 'ask', 854: message: 855: 'Command contains empty quote pair adjacent to quoted dash (potential flag obfuscation)', 856: } 857: } 858: if (/(?:^|\s)['"]{3,}/.test(originalCommand)) { 859: logEvent('tengu_bash_security_check_triggered', { 860: checkId: BASH_SECURITY_CHECK_IDS.OBFUSCATED_FLAGS, 861: subId: 11, 862: }) 863: return { 864: behavior: 'ask', 865: message: 866: 'Command contains consecutive quote characters at word start (potential obfuscation)', 867: } 868: } 869: let inSingleQuote = false 870: let inDoubleQuote = false 871: let escaped = false 872: for (let i = 0; i < originalCommand.length - 1; i++) { 873: const currentChar = originalCommand[i] 874: const nextChar = originalCommand[i + 1] 875: if (escaped) { 876: escaped = false 877: continue 878: } 879: if (currentChar === '\\' && !inSingleQuote) { 880: escaped = true 881: continue 882: } 883: if (currentChar === "'" && !inDoubleQuote) { 884: inSingleQuote = !inSingleQuote 885: continue 886: } 887: if (currentChar === '"' && !inSingleQuote) { 888: inDoubleQuote = !inDoubleQuote 889: continue 890: } 891: // Only look for flags when not inside quoted strings 892: // This prevents false positives like: make test TEST="file.py -v" 893: if (inSingleQuote || inDoubleQuote) { 894: continue 895: } 896: // Look for whitespace followed by quote that contains a dash (potential flag obfuscation) 897: // SECURITY: Block ANY quoted content starting with dash - err on side of safety 898: // Catches: "-"exec, "-file", "--flag", '-'output, etc. 899: // Users can approve manually if legitimate (e.g., find . -name "-file") 900: if ( 901: currentChar && 902: nextChar && 903: /\s/.test(currentChar) && 904: /['"`]/.test(nextChar) 905: ) { 906: const quoteChar = nextChar 907: let j = i + 2 // Start after the opening quote 908: let insideQuote = '' 909: // Collect content inside the quote 910: while (j < originalCommand.length && originalCommand[j] !== quoteChar) { 911: insideQuote += originalCommand[j]! 912: j++ 913: } 914: // If we found a closing quote and the content looks like an obfuscated flag, block it. 915: // Three attack patterns to catch: 916: // 1. Flag name inside quotes: "--flag", "-exec", "-X" (dashes + letters inside) 917: const charAfterQuote = originalCommand[j + 1] 918: const hasFlagCharsInside = /^-+[a-zA-Z0-9$`]/.test(insideQuote) 919: // Characters that can continue a flag after a closing quote. This catches: 920: // a-zA-Z0-9: "-"exec → -exec (direct concatenation) 921: // \\: "-"\exec → -exec (backslash escape is stripped) 922: // -: "-"-output → --output (extra dashes) 923: // {: "-"{exec,delete} → -exec -delete (brace expansion) 924: // $: "-"$VAR → -exec when VAR=exec (variable expansion) 925: // `: "-"`echo exec` → -exec (command substitution) 926: const FLAG_CONTINUATION_CHARS = /[a-zA-Z0-9\\${`-]/ 927: const hasFlagCharsContinuing = 928: /^-+$/.test(insideQuote) && 929: charAfterQuote !== undefined && 930: FLAG_CONTINUATION_CHARS.test(charAfterQuote) 931: // Handle adjacent quote chaining: "-""exec" or "-""-"exec or """-"exec concatenates 932: // to -exec in shell. Follow the chain of adjacent quoted segments until 933: // we find one containing an alphanumeric char or hit a non-quote boundary. 934: // Also handles empty prefix quotes: """-"exec where "" is followed by "-"exec 935: // The combined segments form a flag if they contain dash(es) followed by alphanumerics. 936: const hasFlagCharsInNextQuote = 937: // Trigger when: first segment is only dashes OR empty (could be prefix for flag) 938: (insideQuote === '' || /^-+$/.test(insideQuote)) && 939: charAfterQuote !== undefined && 940: /['"`]/.test(charAfterQuote) && 941: (() => { 942: let pos = j + 1 943: let combinedContent = insideQuote 944: while ( 945: pos < originalCommand.length && 946: /['"`]/.test(originalCommand[pos]!) 947: ) { 948: const segQuote = originalCommand[pos]! 949: let end = pos + 1 950: while ( 951: end < originalCommand.length && 952: originalCommand[end] !== segQuote 953: ) { 954: end++ 955: } 956: const segment = originalCommand.slice(pos + 1, end) 957: combinedContent += segment 958: // Check if combined content so far forms a flag pattern. 959: // Include $ and ` for in-quote expansion: "-""$VAR" → -exec 960: if (/^-+[a-zA-Z0-9$`]/.test(combinedContent)) return true 961: // If this segment has alphanumeric/expansion and we already have dashes, 962: // it's a flag. Catches "-""$*" where segment='$*' has no alnum but 963: const priorContent = 964: segment.length > 0 965: ? combinedContent.slice(0, -segment.length) 966: : combinedContent 967: if (/^-+$/.test(priorContent)) { 968: if (/[a-zA-Z0-9$`]/.test(segment)) return true 969: } 970: if (end >= originalCommand.length) break // Unclosed quote 971: pos = end + 1 // Move past closing quote to check next segment 972: } 973: // Also check the unquoted char at the end of the chain 974: if ( 975: pos < originalCommand.length && 976: FLAG_CONTINUATION_CHARS.test(originalCommand[pos]!) 977: ) { 978: // If we have dashes in combined content, the trailing char completes a flag 979: if (/^-+$/.test(combinedContent) || combinedContent === '') { 980: // Check if we're about to form a flag with the following content 981: const nextChar = originalCommand[pos]! 982: if (nextChar === '-') { 983: // More dashes, could still form a flag 984: return true 985: } 986: if (/[a-zA-Z0-9\\${`]/.test(nextChar) && combinedContent !== '') { 987: // We have dashes and now alphanumeric/expansion follows 988: return true 989: } 990: } 991: // Original check for dashes followed by alphanumeric 992: if (/^-/.test(combinedContent)) { 993: return true 994: } 995: } 996: return false 997: })() 998: if ( 999: j < originalCommand.length && 1000: originalCommand[j] === quoteChar && 1001: (hasFlagCharsInside || 1002: hasFlagCharsContinuing || 1003: hasFlagCharsInNextQuote) 1004: ) { 1005: logEvent('tengu_bash_security_check_triggered', { 1006: checkId: BASH_SECURITY_CHECK_IDS.OBFUSCATED_FLAGS, 1007: subId: 4, 1008: }) 1009: return { 1010: behavior: 'ask', 1011: message: 'Command contains quoted characters in flag names', 1012: } 1013: } 1014: } 1015: if (currentChar && nextChar && /\s/.test(currentChar) && nextChar === '-') { 1016: let j = i + 1 1017: let flagContent = '' 1018: // Collect flag content 1019: while (j < originalCommand.length) { 1020: const flagChar = originalCommand[j] 1021: if (!flagChar) break 1022: // End flag content once we hit whitespace or an equals sign 1023: if (/[\s=]/.test(flagChar)) { 1024: break 1025: } 1026: // End flag collection if we hit quote followed by non-flag character. This is needed to handle cases like -d"," which should be parsed as just -d 1027: if (/['"`]/.test(flagChar)) { 1028: // Special case for cut -d flag: the delimiter value can be quoted 1029: // Example: cut -d'"' should parse as flag name: -d, value: '"' 1030: // Note: We only apply this exception to cut -d specifically to avoid bypasses. 1031: // Without this restriction, a command like `find -e"xec"` could be parsed as 1032: // flag name: -e, bypassing our blocklist for -exec. By restricting to cut -d, 1033: // we allow the legitimate use case while preventing obfuscation attacks on other 1034: // commands where quoted flag values could hide dangerous flag names. 1035: if ( 1036: baseCommand === 'cut' && 1037: flagContent === '-d' && 1038: /['"`]/.test(flagChar) 1039: ) { 1040: break 1041: } 1042: if (j + 1 < originalCommand.length) { 1043: const nextFlagChar = originalCommand[j + 1] 1044: if (nextFlagChar && !/[a-zA-Z0-9_'"-]/.test(nextFlagChar)) { 1045: // Quote followed by something that is clearly not part of a flag, end the parsing 1046: break 1047: } 1048: } 1049: } 1050: flagContent += flagChar 1051: j++ 1052: } 1053: if (flagContent.includes('"') || flagContent.includes("'")) { 1054: logEvent('tengu_bash_security_check_triggered', { 1055: checkId: BASH_SECURITY_CHECK_IDS.OBFUSCATED_FLAGS, 1056: subId: 1, 1057: }) 1058: return { 1059: behavior: 'ask', 1060: message: 'Command contains quoted characters in flag names', 1061: } 1062: } 1063: } 1064: } 1065: // Also handle flags that start with quotes: "--"output, '-'-output, etc. 1066: // Use fullyUnquotedContent to avoid false positives from legitimate quoted content like echo "---" 1067: if (/\s['"`]-/.test(context.fullyUnquotedContent)) { 1068: logEvent('tengu_bash_security_check_triggered', { 1069: checkId: BASH_SECURITY_CHECK_IDS.OBFUSCATED_FLAGS, 1070: subId: 2, 1071: }) 1072: return { 1073: behavior: 'ask', 1074: message: 'Command contains quoted characters in flag names', 1075: } 1076: } 1077: // Also handles cases like ""--output 1078: // Use fullyUnquotedContent to avoid false positives from legitimate quoted content 1079: if (/['"`]{2}-/.test(context.fullyUnquotedContent)) { 1080: logEvent('tengu_bash_security_check_triggered', { 1081: checkId: BASH_SECURITY_CHECK_IDS.OBFUSCATED_FLAGS, 1082: subId: 3, 1083: }) 1084: return { 1085: behavior: 'ask', 1086: message: 'Command contains quoted characters in flag names', 1087: } 1088: } 1089: return { behavior: 'passthrough', message: 'No obfuscated flags detected' } 1090: } 1091: function hasBackslashEscapedWhitespace(command: string): boolean { 1092: let inSingleQuote = false 1093: let inDoubleQuote = false 1094: for (let i = 0; i < command.length; i++) { 1095: const char = command[i] 1096: if (char === '\\' && !inSingleQuote) { 1097: if (!inDoubleQuote) { 1098: const nextChar = command[i + 1] 1099: if (nextChar === ' ' || nextChar === '\t') { 1100: return true 1101: } 1102: } 1103: i++ 1104: continue 1105: } 1106: if (char === '"' && !inSingleQuote) { 1107: inDoubleQuote = !inDoubleQuote 1108: continue 1109: } 1110: if (char === "'" && !inDoubleQuote) { 1111: inSingleQuote = !inSingleQuote 1112: continue 1113: } 1114: } 1115: return false 1116: } 1117: function validateBackslashEscapedWhitespace( 1118: context: ValidationContext, 1119: ): PermissionResult { 1120: if (hasBackslashEscapedWhitespace(context.originalCommand)) { 1121: logEvent('tengu_bash_security_check_triggered', { 1122: checkId: BASH_SECURITY_CHECK_IDS.BACKSLASH_ESCAPED_WHITESPACE, 1123: }) 1124: return { 1125: behavior: 'ask', 1126: message: 1127: 'Command contains backslash-escaped whitespace that could alter command parsing', 1128: } 1129: } 1130: return { 1131: behavior: 'passthrough', 1132: message: 'No backslash-escaped whitespace', 1133: } 1134: } 1135: const SHELL_OPERATORS = new Set([';', '|', '&', '<', '>']) 1136: function hasBackslashEscapedOperator(command: string): boolean { 1137: let inSingleQuote = false 1138: let inDoubleQuote = false 1139: for (let i = 0; i < command.length; i++) { 1140: const char = command[i] 1141: if (char === '\\' && !inSingleQuote) { 1142: // Only flag \<operator> when OUTSIDE double quotes (inside double quotes, 1143: // operators like ;|&<> are already not special, so \; is harmless there). 1144: if (!inDoubleQuote) { 1145: const nextChar = command[i + 1] 1146: if (nextChar && SHELL_OPERATORS.has(nextChar)) { 1147: return true 1148: } 1149: } 1150: // Skip the escaped character unconditionally. Inside double quotes, this 1151: // correctly consumes backslash pairs: `"x\\"` → pos 6 (`\`) skips pos 7 1152: // (`\`), then pos 8 (`"`) toggles inDoubleQuote off correctly. Without 1153: // unconditional skip, pos 7 would see `\`, see pos 8 (`"`) as nextChar, 1154: // skip it, and the closing quote would NEVER toggle inDoubleQuote — 1155: // permanently desyncing and missing subsequent `\;` outside quotes. 1156: // Exploit: `cat "x\\" \; echo /etc/passwd` — bash reads /etc/passwd. 1157: // 1158: // This correctly handles backslash parity: odd-count `\;` (1, 3, 5...) 1159: // is flagged (the unpaired `\` before `;` is detected). Even-count `\\;` 1160: // (2, 4...) is NOT flagged, which is CORRECT — bash treats `\\` as 1161: // literal `\` and `;` as a separator, so splitCommand handles it 1162: // normally (no double-parse bug). This matches 1163: // hasBackslashEscapedWhitespace line ~1340. 1164: i++ 1165: continue 1166: } 1167: // Quote toggles come AFTER backslash handling (backslash already skipped 1168: // any escaped quote char, so these toggles only fire on unescaped quotes). 1169: if (char === "'" && !inDoubleQuote) { 1170: inSingleQuote = !inSingleQuote 1171: continue 1172: } 1173: if (char === '"' && !inSingleQuote) { 1174: inDoubleQuote = !inDoubleQuote 1175: continue 1176: } 1177: } 1178: return false 1179: } 1180: function validateBackslashEscapedOperators( 1181: context: ValidationContext, 1182: ): PermissionResult { 1183: // Tree-sitter path: if tree-sitter confirms no actual operator nodes exist 1184: // in the AST, then any \; is just an escaped character in a word argument 1185: // (e.g., `find . -exec cmd {} \;`). Skip the expensive regex check. 1186: if (context.treeSitter && !context.treeSitter.hasActualOperatorNodes) { 1187: return { behavior: 'passthrough', message: 'No operator nodes in AST' } 1188: } 1189: if (hasBackslashEscapedOperator(context.originalCommand)) { 1190: logEvent('tengu_bash_security_check_triggered', { 1191: checkId: BASH_SECURITY_CHECK_IDS.BACKSLASH_ESCAPED_OPERATORS, 1192: }) 1193: return { 1194: behavior: 'ask', 1195: message: 1196: 'Command contains a backslash before a shell operator (;, |, &, <, >) which can hide command structure', 1197: } 1198: } 1199: return { 1200: behavior: 'passthrough', 1201: message: 'No backslash-escaped operators', 1202: } 1203: } 1204: function isEscapedAtPosition(content: string, pos: number): boolean { 1205: let backslashCount = 0 1206: let i = pos - 1 1207: while (i >= 0 && content[i] === '\\') { 1208: backslashCount++ 1209: i-- 1210: } 1211: return backslashCount % 2 === 1 1212: } 1213: /** 1214: * Detects unquoted brace expansion syntax that Bash expands but shell-quote/tree-sitter 1215: * treat as literal strings. This parsing discrepancy allows permission bypass: 1216: * git ls-remote {--upload-pack="touch /tmp/test",test} 1217: * Parser sees one literal arg, but Bash expands to: --upload-pack="touch /tmp/test" test 1218: * 1219: * Brace expansion has two forms: 1220: * 1. Comma-separated: {a,b,c} → a b c 1221: * 2. Sequence: {1..5} → 1 2 3 4 5 1222: * 1223: * Both single and double quotes suppress brace expansion in Bash, so we use 1224: * fullyUnquotedContent which has both quote types stripped. 1225: * Backslash-escaped braces (\{, \}) also suppress expansion. 1226: */ 1227: function validateBraceExpansion(context: ValidationContext): PermissionResult { 1228: // Use pre-strip content to avoid false negatives from stripSafeRedirections 1229: // creating backslash adjacencies (e.g., `\>/dev/null{a,b}` → `\{a,b}` after 1230: // stripping, making isEscapedAtPosition think the brace is escaped). 1231: const content = context.fullyUnquotedPreStrip 1232: // SECURITY: Check for MISMATCHED brace counts in fullyUnquoted content. 1233: // A mismatch indicates that quoted braces (e.g., `'{'` or `"{"`) were 1234: // stripped by extractQuotedContent, leaving unbalanced braces in the content 1235: // we analyze. Our depth-matching algorithm below assumes balanced braces — 1236: // with a mismatch, it closes at the WRONG position, missing commas that 1237: // bash's algorithm WOULD find. 1238: let unescapedOpenBraces = 0 1239: let unescapedCloseBraces = 0 1240: for (let i = 0; i < content.length; i++) { 1241: if (content[i] === '{' && !isEscapedAtPosition(content, i)) { 1242: unescapedOpenBraces++ 1243: } else if (content[i] === '}' && !isEscapedAtPosition(content, i)) { 1244: unescapedCloseBraces++ 1245: } 1246: } 1247: if (unescapedOpenBraces > 0 && unescapedCloseBraces > unescapedOpenBraces) { 1248: logEvent('tengu_bash_security_check_triggered', { 1249: checkId: BASH_SECURITY_CHECK_IDS.BRACE_EXPANSION, 1250: subId: 2, 1251: }) 1252: return { 1253: behavior: 'ask', 1254: message: 1255: 'Command has excess closing braces after quote stripping, indicating possible brace expansion obfuscation', 1256: } 1257: } 1258: if (unescapedOpenBraces > 0) { 1259: const orig = context.originalCommand 1260: if (/['"][{}]['"]/.test(orig)) { 1261: logEvent('tengu_bash_security_check_triggered', { 1262: checkId: BASH_SECURITY_CHECK_IDS.BRACE_EXPANSION, 1263: subId: 3, 1264: }) 1265: return { 1266: behavior: 'ask', 1267: message: 1268: 'Command contains quoted brace character inside brace context (potential brace expansion obfuscation)', 1269: } 1270: } 1271: } 1272: // Scan for unescaped `{` characters, then check if they form brace expansion. 1273: // We use a manual scan rather than a simple regex lookbehind because 1274: // lookbehinds can't handle double-escaped backslashes (\\{ is unescaped `{`). 1275: for (let i = 0; i < content.length; i++) { 1276: if (content[i] !== '{') continue 1277: if (isEscapedAtPosition(content, i)) continue 1278: // Find matching unescaped `}` by tracking nesting depth. 1279: // Previous approach broke on nested `{`, missing commas between the outer 1280: // `{` and the nested one (e.g., `{--upload-pack="evil",{test}}`). 1281: let depth = 1 1282: let matchingClose = -1 1283: for (let j = i + 1; j < content.length; j++) { 1284: const ch = content[j] 1285: if (ch === '{' && !isEscapedAtPosition(content, j)) { 1286: depth++ 1287: } else if (ch === '}' && !isEscapedAtPosition(content, j)) { 1288: depth-- 1289: if (depth === 0) { 1290: matchingClose = j 1291: break 1292: } 1293: } 1294: } 1295: if (matchingClose === -1) continue 1296: // Check for `,` or `..` at the outermost nesting level between this 1297: // `{` and its matching `}`. Only depth-0 triggers matter — bash splits 1298: // brace expansion at outer-level commas/sequences. 1299: let innerDepth = 0 1300: for (let k = i + 1; k < matchingClose; k++) { 1301: const ch = content[k] 1302: if (ch === '{' && !isEscapedAtPosition(content, k)) { 1303: innerDepth++ 1304: } else if (ch === '}' && !isEscapedAtPosition(content, k)) { 1305: innerDepth-- 1306: } else if (innerDepth === 0) { 1307: if ( 1308: ch === ',' || 1309: (ch === '.' && k + 1 < matchingClose && content[k + 1] === '.') 1310: ) { 1311: logEvent('tengu_bash_security_check_triggered', { 1312: checkId: BASH_SECURITY_CHECK_IDS.BRACE_EXPANSION, 1313: subId: 1, 1314: }) 1315: return { 1316: behavior: 'ask', 1317: message: 1318: 'Command contains brace expansion that could alter command parsing', 1319: } 1320: } 1321: } 1322: } 1323: // No expansion at this level — don't skip past; inner pairs will be 1324: // caught by subsequent iterations of the outer loop. 1325: } 1326: return { 1327: behavior: 'passthrough', 1328: message: 'No brace expansion detected', 1329: } 1330: } 1331: // Matches Unicode whitespace characters that shell-quote treats as word 1332: // separators but bash treats as literal word content. While this differential 1333: // is defense-favorable (shell-quote over-splits), blocking these proactively 1334: // prevents future edge cases. 1335: // eslint-disable-next-line no-misleading-character-class 1336: const UNICODE_WS_RE = 1337: /[\u00A0\u1680\u2000-\u200A\u2028\u2029\u202F\u205F\u3000\uFEFF]/ 1338: function validateUnicodeWhitespace( 1339: context: ValidationContext, 1340: ): PermissionResult { 1341: const { originalCommand } = context 1342: if (UNICODE_WS_RE.test(originalCommand)) { 1343: logEvent('tengu_bash_security_check_triggered', { 1344: checkId: BASH_SECURITY_CHECK_IDS.UNICODE_WHITESPACE, 1345: }) 1346: return { 1347: behavior: 'ask', 1348: message: 1349: 'Command contains Unicode whitespace characters that could cause parsing inconsistencies', 1350: } 1351: } 1352: return { behavior: 'passthrough', message: 'No Unicode whitespace' } 1353: } 1354: function validateMidWordHash(context: ValidationContext): PermissionResult { 1355: const { unquotedKeepQuoteChars } = context 1356: // Match # preceded by a non-whitespace character (mid-word hash). 1357: // shell-quote treats mid-word # as comment-start but bash treats it as a 1358: // literal character, creating a parser differential. 1359: // 1360: // Uses unquotedKeepQuoteChars (which preserves quote delimiters but strips 1361: // quoted content) to catch quote-adjacent # like 'x'# — fullyUnquotedPreStrip 1362: // would strip both quotes and content, turning 'x'# into just # (word-start). 1363: // 1364: // SECURITY: Also check the CONTINUATION-JOINED version. The context is built 1365: // from the original command (pre-continuation-join). For `foo\<NL>#bar`, 1366: const joined = unquotedKeepQuoteChars.replace(/\\+\n/g, match => { 1367: const backslashCount = match.length - 1 1368: return backslashCount % 2 === 1 ? '\\'.repeat(backslashCount - 1) : match 1369: }) 1370: if ( 1371: // eslint-disable-next-line custom-rules/no-lookbehind-regex -- .test() with atom search: fast when # absent 1372: /\S(?<!\$\{)#/.test(unquotedKeepQuoteChars) || 1373: // eslint-disable-next-line custom-rules/no-lookbehind-regex -- same as above 1374: /\S(?<!\$\{)#/.test(joined) 1375: ) { 1376: logEvent('tengu_bash_security_check_triggered', { 1377: checkId: BASH_SECURITY_CHECK_IDS.MID_WORD_HASH, 1378: }) 1379: return { 1380: behavior: 'ask', 1381: message: 1382: 'Command contains mid-word # which is parsed differently by shell-quote vs bash', 1383: } 1384: } 1385: return { behavior: 'passthrough', message: 'No mid-word hash' } 1386: } 1387: function validateCommentQuoteDesync( 1388: context: ValidationContext, 1389: ): PermissionResult { 1390: if (context.treeSitter) { 1391: return { 1392: behavior: 'passthrough', 1393: message: 'Tree-sitter quote context is authoritative', 1394: } 1395: } 1396: const { originalCommand } = context 1397: let inSingleQuote = false 1398: let inDoubleQuote = false 1399: let escaped = false 1400: for (let i = 0; i < originalCommand.length; i++) { 1401: const char = originalCommand[i] 1402: if (escaped) { 1403: escaped = false 1404: continue 1405: } 1406: if (inSingleQuote) { 1407: if (char === "'") inSingleQuote = false 1408: continue 1409: } 1410: if (char === '\\') { 1411: escaped = true 1412: continue 1413: } 1414: if (inDoubleQuote) { 1415: if (char === '"') inDoubleQuote = false 1416: // Single quotes inside double quotes are literal — no toggle 1417: continue 1418: } 1419: if (char === "'") { 1420: inSingleQuote = true 1421: continue 1422: } 1423: if (char === '"') { 1424: inDoubleQuote = true 1425: continue 1426: } 1427: // Unquoted `#` — in bash, this starts a comment. Check if the rest of 1428: // the line contains quote characters that would desync other trackers. 1429: if (char === '#') { 1430: const lineEnd = originalCommand.indexOf('\n', i) 1431: const commentText = originalCommand.slice( 1432: i + 1, 1433: lineEnd === -1 ? originalCommand.length : lineEnd, 1434: ) 1435: if (/['"]/.test(commentText)) { 1436: logEvent('tengu_bash_security_check_triggered', { 1437: checkId: BASH_SECURITY_CHECK_IDS.COMMENT_QUOTE_DESYNC, 1438: }) 1439: return { 1440: behavior: 'ask', 1441: message: 1442: 'Command contains quote characters inside a # comment which can desync quote tracking', 1443: } 1444: } 1445: if (lineEnd === -1) break 1446: i = lineEnd 1447: } 1448: } 1449: return { behavior: 'passthrough', message: 'No comment quote desync' } 1450: } 1451: function validateQuotedNewline(context: ValidationContext): PermissionResult { 1452: const { originalCommand } = context 1453: if (!originalCommand.includes('\n') || !originalCommand.includes('#')) { 1454: return { behavior: 'passthrough', message: 'No newline or no hash' } 1455: } 1456: let inSingleQuote = false 1457: let inDoubleQuote = false 1458: let escaped = false 1459: for (let i = 0; i < originalCommand.length; i++) { 1460: const char = originalCommand[i] 1461: if (escaped) { 1462: escaped = false 1463: continue 1464: } 1465: if (char === '\\' && !inSingleQuote) { 1466: escaped = true 1467: continue 1468: } 1469: if (char === "'" && !inDoubleQuote) { 1470: inSingleQuote = !inSingleQuote 1471: continue 1472: } 1473: if (char === '"' && !inSingleQuote) { 1474: inDoubleQuote = !inDoubleQuote 1475: continue 1476: } 1477: // A newline inside quotes: the NEXT line (from bash's perspective) starts 1478: if (char === '\n' && (inSingleQuote || inDoubleQuote)) { 1479: const lineStart = i + 1 1480: const nextNewline = originalCommand.indexOf('\n', lineStart) 1481: const lineEnd = nextNewline === -1 ? originalCommand.length : nextNewline 1482: const nextLine = originalCommand.slice(lineStart, lineEnd) 1483: if (nextLine.trim().startsWith('#')) { 1484: logEvent('tengu_bash_security_check_triggered', { 1485: checkId: BASH_SECURITY_CHECK_IDS.QUOTED_NEWLINE, 1486: }) 1487: return { 1488: behavior: 'ask', 1489: message: 1490: 'Command contains a quoted newline followed by a #-prefixed line, which can hide arguments from line-based permission checks', 1491: } 1492: } 1493: } 1494: } 1495: return { behavior: 'passthrough', message: 'No quoted newline-hash pattern' } 1496: } 1497: function validateZshDangerousCommands( 1498: context: ValidationContext, 1499: ): PermissionResult { 1500: const { originalCommand } = context 1501: const ZSH_PRECOMMAND_MODIFIERS = new Set([ 1502: 'command', 1503: 'builtin', 1504: 'noglob', 1505: 'nocorrect', 1506: ]) 1507: const trimmed = originalCommand.trim() 1508: const tokens = trimmed.split(/\s+/) 1509: let baseCmd = '' 1510: for (const token of tokens) { 1511: // Skip env var assignments (VAR=value) 1512: if (/^[A-Za-z_]\w*=/.test(token)) continue 1513: // Skip Zsh precommand modifiers (they don't change what command runs) 1514: if (ZSH_PRECOMMAND_MODIFIERS.has(token)) continue 1515: baseCmd = token 1516: break 1517: } 1518: if (ZSH_DANGEROUS_COMMANDS.has(baseCmd)) { 1519: logEvent('tengu_bash_security_check_triggered', { 1520: checkId: BASH_SECURITY_CHECK_IDS.ZSH_DANGEROUS_COMMANDS, 1521: subId: 1, 1522: }) 1523: return { 1524: behavior: 'ask', 1525: message: `Command uses Zsh-specific '${baseCmd}' which can bypass security checks`, 1526: } 1527: } 1528: if (baseCmd === 'fc' && /\s-\S*e/.test(trimmed)) { 1529: logEvent('tengu_bash_security_check_triggered', { 1530: checkId: BASH_SECURITY_CHECK_IDS.ZSH_DANGEROUS_COMMANDS, 1531: subId: 2, 1532: }) 1533: return { 1534: behavior: 'ask', 1535: message: 1536: "Command uses 'fc -e' which can execute arbitrary commands via editor", 1537: } 1538: } 1539: return { 1540: behavior: 'passthrough', 1541: message: 'No Zsh dangerous commands', 1542: } 1543: } 1544: const CONTROL_CHAR_RE = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/ 1545: export function bashCommandIsSafe_DEPRECATED( 1546: command: string, 1547: ): PermissionResult { 1548: if (CONTROL_CHAR_RE.test(command)) { 1549: logEvent('tengu_bash_security_check_triggered', { 1550: checkId: BASH_SECURITY_CHECK_IDS.CONTROL_CHARACTERS, 1551: }) 1552: return { 1553: behavior: 'ask', 1554: message: 1555: 'Command contains non-printable control characters that could be used to bypass security checks', 1556: isBashSecurityCheckForMisparsing: true, 1557: } 1558: } 1559: if (hasShellQuoteSingleQuoteBug(command)) { 1560: return { 1561: behavior: 'ask', 1562: message: 1563: 'Command contains single-quoted backslash pattern that could bypass security checks', 1564: isBashSecurityCheckForMisparsing: true, 1565: } 1566: } 1567: const { processedCommand } = extractHeredocs(command, { quotedOnly: true }) 1568: const baseCommand = command.split(' ')[0] || '' 1569: const { withDoubleQuotes, fullyUnquoted, unquotedKeepQuoteChars } = 1570: extractQuotedContent(processedCommand, baseCommand === 'jq') 1571: const context: ValidationContext = { 1572: originalCommand: command, 1573: baseCommand, 1574: unquotedContent: withDoubleQuotes, 1575: fullyUnquotedContent: stripSafeRedirections(fullyUnquoted), 1576: fullyUnquotedPreStrip: fullyUnquoted, 1577: unquotedKeepQuoteChars, 1578: } 1579: const earlyValidators = [ 1580: validateEmpty, 1581: validateIncompleteCommands, 1582: validateSafeCommandSubstitution, 1583: validateGitCommit, 1584: ] 1585: for (const validator of earlyValidators) { 1586: const result = validator(context) 1587: if (result.behavior === 'allow') { 1588: return { 1589: behavior: 'passthrough', 1590: message: 1591: result.decisionReason?.type === 'other' || 1592: result.decisionReason?.type === 'safetyCheck' 1593: ? result.decisionReason.reason 1594: : 'Command allowed', 1595: } 1596: } 1597: if (result.behavior !== 'passthrough') { 1598: return result.behavior === 'ask' 1599: ? { ...result, isBashSecurityCheckForMisparsing: true as const } 1600: : result 1601: } 1602: } 1603: const nonMisparsingValidators = new Set([ 1604: validateNewlines, 1605: validateRedirections, 1606: ]) 1607: const validators = [ 1608: validateJqCommand, 1609: validateObfuscatedFlags, 1610: validateShellMetacharacters, 1611: validateDangerousVariables, 1612: validateCommentQuoteDesync, 1613: validateQuotedNewline, 1614: validateCarriageReturn, 1615: validateNewlines, 1616: validateIFSInjection, 1617: validateProcEnvironAccess, 1618: validateDangerousPatterns, 1619: validateRedirections, 1620: validateBackslashEscapedWhitespace, 1621: validateBackslashEscapedOperators, 1622: validateUnicodeWhitespace, 1623: validateMidWordHash, 1624: validateBraceExpansion, 1625: validateZshDangerousCommands, 1626: validateMalformedTokenInjection, 1627: ] 1628: let deferredNonMisparsingResult: PermissionResult | null = null 1629: for (const validator of validators) { 1630: const result = validator(context) 1631: if (result.behavior === 'ask') { 1632: if (nonMisparsingValidators.has(validator)) { 1633: if (deferredNonMisparsingResult === null) { 1634: deferredNonMisparsingResult = result 1635: } 1636: continue 1637: } 1638: return { ...result, isBashSecurityCheckForMisparsing: true as const } 1639: } 1640: } 1641: if (deferredNonMisparsingResult !== null) { 1642: return deferredNonMisparsingResult 1643: } 1644: return { 1645: behavior: 'passthrough', 1646: message: 'Command passed all security checks', 1647: } 1648: } 1649: export async function bashCommandIsSafeAsync_DEPRECATED( 1650: command: string, 1651: onDivergence?: () => void, 1652: ): Promise<PermissionResult> { 1653: const parsed = await ParsedCommand.parse(command) 1654: const tsAnalysis = parsed?.getTreeSitterAnalysis() ?? null 1655: if (!tsAnalysis) { 1656: return bashCommandIsSafe_DEPRECATED(command) 1657: } 1658: if (CONTROL_CHAR_RE.test(command)) { 1659: logEvent('tengu_bash_security_check_triggered', { 1660: checkId: BASH_SECURITY_CHECK_IDS.CONTROL_CHARACTERS, 1661: }) 1662: return { 1663: behavior: 'ask', 1664: message: 1665: 'Command contains non-printable control characters that could be used to bypass security checks', 1666: isBashSecurityCheckForMisparsing: true, 1667: } 1668: } 1669: if (hasShellQuoteSingleQuoteBug(command)) { 1670: return { 1671: behavior: 'ask', 1672: message: 1673: 'Command contains single-quoted backslash pattern that could bypass security checks', 1674: isBashSecurityCheckForMisparsing: true, 1675: } 1676: } 1677: const { processedCommand } = extractHeredocs(command, { quotedOnly: true }) 1678: const baseCommand = command.split(' ')[0] || '' 1679: // Use tree-sitter quote context for more accurate analysis 1680: const tsQuote = tsAnalysis.quoteContext 1681: const regexQuote = extractQuotedContent( 1682: processedCommand, 1683: baseCommand === 'jq', 1684: ) 1685: const withDoubleQuotes = tsQuote.withDoubleQuotes 1686: const fullyUnquoted = tsQuote.fullyUnquoted 1687: const unquotedKeepQuoteChars = tsQuote.unquotedKeepQuoteChars 1688: const context: ValidationContext = { 1689: originalCommand: command, 1690: baseCommand, 1691: unquotedContent: withDoubleQuotes, 1692: fullyUnquotedContent: stripSafeRedirections(fullyUnquoted), 1693: fullyUnquotedPreStrip: fullyUnquoted, 1694: unquotedKeepQuoteChars, 1695: treeSitter: tsAnalysis, 1696: } 1697: if (!tsAnalysis.dangerousPatterns.hasHeredoc) { 1698: const hasDivergence = 1699: tsQuote.fullyUnquoted !== regexQuote.fullyUnquoted || 1700: tsQuote.withDoubleQuotes !== regexQuote.withDoubleQuotes 1701: if (hasDivergence) { 1702: if (onDivergence) { 1703: onDivergence() 1704: } else { 1705: logEvent('tengu_tree_sitter_security_divergence', { 1706: quoteContextDivergence: true, 1707: }) 1708: } 1709: } 1710: } 1711: const earlyValidators = [ 1712: validateEmpty, 1713: validateIncompleteCommands, 1714: validateSafeCommandSubstitution, 1715: validateGitCommit, 1716: ] 1717: for (const validator of earlyValidators) { 1718: const result = validator(context) 1719: if (result.behavior === 'allow') { 1720: return { 1721: behavior: 'passthrough', 1722: message: 1723: result.decisionReason?.type === 'other' || 1724: result.decisionReason?.type === 'safetyCheck' 1725: ? result.decisionReason.reason 1726: : 'Command allowed', 1727: } 1728: } 1729: if (result.behavior !== 'passthrough') { 1730: return result.behavior === 'ask' 1731: ? { ...result, isBashSecurityCheckForMisparsing: true as const } 1732: : result 1733: } 1734: } 1735: const nonMisparsingValidators = new Set([ 1736: validateNewlines, 1737: validateRedirections, 1738: ]) 1739: const validators = [ 1740: validateJqCommand, 1741: validateObfuscatedFlags, 1742: validateShellMetacharacters, 1743: validateDangerousVariables, 1744: validateCommentQuoteDesync, 1745: validateQuotedNewline, 1746: validateCarriageReturn, 1747: validateNewlines, 1748: validateIFSInjection, 1749: validateProcEnvironAccess, 1750: validateDangerousPatterns, 1751: validateRedirections, 1752: validateBackslashEscapedWhitespace, 1753: validateBackslashEscapedOperators, 1754: validateUnicodeWhitespace, 1755: validateMidWordHash, 1756: validateBraceExpansion, 1757: validateZshDangerousCommands, 1758: validateMalformedTokenInjection, 1759: ] 1760: let deferredNonMisparsingResult: PermissionResult | null = null 1761: for (const validator of validators) { 1762: const result = validator(context) 1763: if (result.behavior === 'ask') { 1764: if (nonMisparsingValidators.has(validator)) { 1765: if (deferredNonMisparsingResult === null) { 1766: deferredNonMisparsingResult = result 1767: } 1768: continue 1769: } 1770: return { ...result, isBashSecurityCheckForMisparsing: true as const } 1771: } 1772: } 1773: if (deferredNonMisparsingResult !== null) { 1774: return deferredNonMisparsingResult 1775: } 1776: return { 1777: behavior: 'passthrough', 1778: message: 'Command passed all security checks', 1779: } 1780: }

File: src/tools/BashTool/BashTool.tsx

typescript 1: import { feature } from 'bun:bundle'; 2: import type { ToolResultBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'; 3: import { copyFile, stat as fsStat, truncate as fsTruncate, link } from 'fs/promises'; 4: import * as React from 'react'; 5: import type { CanUseToolFn } from 'src/hooks/useCanUseTool.js'; 6: import type { AppState } from 'src/state/AppState.js'; 7: import { z } from 'zod/v4'; 8: import { getKairosActive } from '../../bootstrap/state.js'; 9: import { TOOL_SUMMARY_MAX_LENGTH } from '../../constants/toolLimits.js'; 10: import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from '../../services/analytics/index.js'; 11: import { notifyVscodeFileUpdated } from '../../services/mcp/vscodeSdkMcp.js'; 12: import type { SetToolJSXFn, ToolCallProgress, ToolUseContext, ValidationResult } from '../../Tool.js'; 13: import { buildTool, type ToolDef } from '../../Tool.js'; 14: import { backgroundExistingForegroundTask, markTaskNotified, registerForeground, spawnShellTask, unregisterForeground } from '../../tasks/LocalShellTask/LocalShellTask.js'; 15: import type { AgentId } from '../../types/ids.js'; 16: import type { AssistantMessage } from '../../types/message.js'; 17: import { parseForSecurity } from '../../utils/bash/ast.js'; 18: import { splitCommand_DEPRECATED, splitCommandWithOperators } from '../../utils/bash/commands.js'; 19: import { extractClaudeCodeHints } from '../../utils/claudeCodeHints.js'; 20: import { detectCodeIndexingFromCommand } from '../../utils/codeIndexing.js'; 21: import { isEnvTruthy } from '../../utils/envUtils.js'; 22: import { isENOENT, ShellError } from '../../utils/errors.js'; 23: import { detectFileEncoding, detectLineEndings, getFileModificationTime, writeTextContent } from '../../utils/file.js'; 24: import { fileHistoryEnabled, fileHistoryTrackEdit } from '../../utils/fileHistory.js'; 25: import { truncate } from '../../utils/format.js'; 26: import { getFsImplementation } from '../../utils/fsOperations.js'; 27: import { lazySchema } from '../../utils/lazySchema.js'; 28: import { expandPath } from '../../utils/path.js'; 29: import type { PermissionResult } from '../../utils/permissions/PermissionResult.js'; 30: import { maybeRecordPluginHint } from '../../utils/plugins/hintRecommendation.js'; 31: import { exec } from '../../utils/Shell.js'; 32: import type { ExecResult } from '../../utils/ShellCommand.js'; 33: import { SandboxManager } from '../../utils/sandbox/sandbox-adapter.js'; 34: import { semanticBoolean } from '../../utils/semanticBoolean.js'; 35: import { semanticNumber } from '../../utils/semanticNumber.js'; 36: import { EndTruncatingAccumulator } from '../../utils/stringUtils.js'; 37: import { getTaskOutputPath } from '../../utils/task/diskOutput.js'; 38: import { TaskOutput } from '../../utils/task/TaskOutput.js'; 39: import { isOutputLineTruncated } from '../../utils/terminal.js'; 40: import { buildLargeToolResultMessage, ensureToolResultsDir, generatePreview, getToolResultPath, PREVIEW_SIZE_BYTES } from '../../utils/toolResultStorage.js'; 41: import { userFacingName as fileEditUserFacingName } from '../FileEditTool/UI.js'; 42: import { trackGitOperations } from '../shared/gitOperationTracking.js'; 43: import { bashToolHasPermission, commandHasAnyCd, matchWildcardPattern, permissionRuleExtractPrefix } from './bashPermissions.js'; 44: import { interpretCommandResult } from './commandSemantics.js'; 45: import { getDefaultTimeoutMs, getMaxTimeoutMs, getSimplePrompt } from './prompt.js'; 46: import { checkReadOnlyConstraints } from './readOnlyValidation.js'; 47: import { parseSedEditCommand } from './sedEditParser.js'; 48: import { shouldUseSandbox } from './shouldUseSandbox.js'; 49: import { BASH_TOOL_NAME } from './toolName.js'; 50: import { BackgroundHint, renderToolResultMessage, renderToolUseErrorMessage, renderToolUseMessage, renderToolUseProgressMessage, renderToolUseQueuedMessage } from './UI.js'; 51: import { buildImageToolResult, isImageOutput, resetCwdIfOutsideProject, resizeShellImageOutput, stdErrAppendShellResetMessage, stripEmptyLines } from './utils.js'; 52: const EOL = '\n'; 53: const PROGRESS_THRESHOLD_MS = 2000; 54: const ASSISTANT_BLOCKING_BUDGET_MS = 15_000; 55: const BASH_SEARCH_COMMANDS = new Set(['find', 'grep', 'rg', 'ag', 'ack', 'locate', 'which', 'whereis']); 56: const BASH_READ_COMMANDS = new Set(['cat', 'head', 'tail', 'less', 'more', 57: 'wc', 'stat', 'file', 'strings', 58: 'jq', 'awk', 'cut', 'sort', 'uniq', 'tr']); 59: const BASH_LIST_COMMANDS = new Set(['ls', 'tree', 'du']); 60: const BASH_SEMANTIC_NEUTRAL_COMMANDS = new Set(['echo', 'printf', 'true', 'false', ':' 61: ]); 62: const BASH_SILENT_COMMANDS = new Set(['mv', 'cp', 'rm', 'mkdir', 'rmdir', 'chmod', 'chown', 'chgrp', 'touch', 'ln', 'cd', 'export', 'unset', 'wait']); 63: export function isSearchOrReadBashCommand(command: string): { 64: isSearch: boolean; 65: isRead: boolean; 66: isList: boolean; 67: } { 68: let partsWithOperators: string[]; 69: try { 70: partsWithOperators = splitCommandWithOperators(command); 71: } catch { 72: return { 73: isSearch: false, 74: isRead: false, 75: isList: false 76: }; 77: } 78: if (partsWithOperators.length === 0) { 79: return { 80: isSearch: false, 81: isRead: false, 82: isList: false 83: }; 84: } 85: let hasSearch = false; 86: let hasRead = false; 87: let hasList = false; 88: let hasNonNeutralCommand = false; 89: let skipNextAsRedirectTarget = false; 90: for (const part of partsWithOperators) { 91: if (skipNextAsRedirectTarget) { 92: skipNextAsRedirectTarget = false; 93: continue; 94: } 95: if (part === '>' || part === '>>' || part === '>&') { 96: skipNextAsRedirectTarget = true; 97: continue; 98: } 99: if (part === '||' || part === '&&' || part === '|' || part === ';') { 100: continue; 101: } 102: const baseCommand = part.trim().split(/\s+/)[0]; 103: if (!baseCommand) { 104: continue; 105: } 106: if (BASH_SEMANTIC_NEUTRAL_COMMANDS.has(baseCommand)) { 107: continue; 108: } 109: hasNonNeutralCommand = true; 110: const isPartSearch = BASH_SEARCH_COMMANDS.has(baseCommand); 111: const isPartRead = BASH_READ_COMMANDS.has(baseCommand); 112: const isPartList = BASH_LIST_COMMANDS.has(baseCommand); 113: if (!isPartSearch && !isPartRead && !isPartList) { 114: return { 115: isSearch: false, 116: isRead: false, 117: isList: false 118: }; 119: } 120: if (isPartSearch) hasSearch = true; 121: if (isPartRead) hasRead = true; 122: if (isPartList) hasList = true; 123: } 124: if (!hasNonNeutralCommand) { 125: return { 126: isSearch: false, 127: isRead: false, 128: isList: false 129: }; 130: } 131: return { 132: isSearch: hasSearch, 133: isRead: hasRead, 134: isList: hasList 135: }; 136: } 137: function isSilentBashCommand(command: string): boolean { 138: let partsWithOperators: string[]; 139: try { 140: partsWithOperators = splitCommandWithOperators(command); 141: } catch { 142: return false; 143: } 144: if (partsWithOperators.length === 0) { 145: return false; 146: } 147: let hasNonFallbackCommand = false; 148: let lastOperator: string | null = null; 149: let skipNextAsRedirectTarget = false; 150: for (const part of partsWithOperators) { 151: if (skipNextAsRedirectTarget) { 152: skipNextAsRedirectTarget = false; 153: continue; 154: } 155: if (part === '>' || part === '>>' || part === '>&') { 156: skipNextAsRedirectTarget = true; 157: continue; 158: } 159: if (part === '||' || part === '&&' || part === '|' || part === ';') { 160: lastOperator = part; 161: continue; 162: } 163: const baseCommand = part.trim().split(/\s+/)[0]; 164: if (!baseCommand) { 165: continue; 166: } 167: if (lastOperator === '||' && BASH_SEMANTIC_NEUTRAL_COMMANDS.has(baseCommand)) { 168: continue; 169: } 170: hasNonFallbackCommand = true; 171: if (!BASH_SILENT_COMMANDS.has(baseCommand)) { 172: return false; 173: } 174: } 175: return hasNonFallbackCommand; 176: } 177: const DISALLOWED_AUTO_BACKGROUND_COMMANDS = ['sleep' 178: ]; 179: const isBackgroundTasksDisabled = 180: isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_BACKGROUND_TASKS); 181: const fullInputSchema = lazySchema(() => z.strictObject({ 182: command: z.string().describe('The command to execute'), 183: timeout: semanticNumber(z.number().optional()).describe(`Optional timeout in milliseconds (max ${getMaxTimeoutMs()})`), 184: description: z.string().optional().describe(`Clear, concise description of what this command does in active voice. Never use words like "complex" or "risk" in the description - just describe what it does. 185: For simple commands (git, npm, standard CLI tools), keep it brief (5-10 words): 186: - ls → "List files in current directory" 187: - git status → "Show working tree status" 188: - npm install → "Install package dependencies" 189: For commands that are harder to parse at a glance (piped commands, obscure flags, etc.), add enough context to clarify what it does: 190: - find . -name "*.tmp" -exec rm {} \\; → "Find and delete all .tmp files recursively" 191: - git reset --hard origin/main → "Discard all local changes and match remote main" 192: - curl -s url | jq '.data[]' → "Fetch JSON from URL and extract data array elements"`), 193: run_in_background: semanticBoolean(z.boolean().optional()).describe(`Set to true to run this command in the background. Use Read to read the output later.`), 194: dangerouslyDisableSandbox: semanticBoolean(z.boolean().optional()).describe('Set this to true to dangerously override sandbox mode and run commands without sandboxing.'), 195: _simulatedSedEdit: z.object({ 196: filePath: z.string(), 197: newContent: z.string() 198: }).optional().describe('Internal: pre-computed sed edit result from preview') 199: })); 200: const inputSchema = lazySchema(() => isBackgroundTasksDisabled ? fullInputSchema().omit({ 201: run_in_background: true, 202: _simulatedSedEdit: true 203: }) : fullInputSchema().omit({ 204: _simulatedSedEdit: true 205: })); 206: type InputSchema = ReturnType<typeof inputSchema>; 207: export type BashToolInput = z.infer<ReturnType<typeof fullInputSchema>>; 208: const COMMON_BACKGROUND_COMMANDS = ['npm', 'yarn', 'pnpm', 'node', 'python', 'python3', 'go', 'cargo', 'make', 'docker', 'terraform', 'webpack', 'vite', 'jest', 'pytest', 'curl', 'wget', 'build', 'test', 'serve', 'watch', 'dev'] as const; 209: function getCommandTypeForLogging(command: string): AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS { 210: const parts = splitCommand_DEPRECATED(command); 211: if (parts.length === 0) return 'other' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS; 212: for (const part of parts) { 213: const baseCommand = part.split(' ')[0] || ''; 214: if (COMMON_BACKGROUND_COMMANDS.includes(baseCommand as (typeof COMMON_BACKGROUND_COMMANDS)[number])) { 215: return baseCommand as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS; 216: } 217: } 218: return 'other' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS; 219: } 220: const outputSchema = lazySchema(() => z.object({ 221: stdout: z.string().describe('The standard output of the command'), 222: stderr: z.string().describe('The standard error output of the command'), 223: rawOutputPath: z.string().optional().describe('Path to raw output file for large MCP tool outputs'), 224: interrupted: z.boolean().describe('Whether the command was interrupted'), 225: isImage: z.boolean().optional().describe('Flag to indicate if stdout contains image data'), 226: backgroundTaskId: z.string().optional().describe('ID of the background task if command is running in background'), 227: backgroundedByUser: z.boolean().optional().describe('True if the user manually backgrounded the command with Ctrl+B'), 228: assistantAutoBackgrounded: z.boolean().optional().describe('True if assistant-mode auto-backgrounded a long-running blocking command'), 229: dangerouslyDisableSandbox: z.boolean().optional().describe('Flag to indicate if sandbox mode was overridden'), 230: returnCodeInterpretation: z.string().optional().describe('Semantic interpretation for non-error exit codes with special meaning'), 231: noOutputExpected: z.boolean().optional().describe('Whether the command is expected to produce no output on success'), 232: structuredContent: z.array(z.any()).optional().describe('Structured content blocks'), 233: persistedOutputPath: z.string().optional().describe('Path to the persisted full output in tool-results dir (set when output is too large for inline)'), 234: persistedOutputSize: z.number().optional().describe('Total size of the output in bytes (set when output is too large for inline)') 235: })); 236: type OutputSchema = ReturnType<typeof outputSchema>; 237: export type Out = z.infer<OutputSchema>; 238: export type { BashProgress } from '../../types/tools.js'; 239: import type { BashProgress } from '../../types/tools.js'; 240: function isAutobackgroundingAllowed(command: string): boolean { 241: const parts = splitCommand_DEPRECATED(command); 242: if (parts.length === 0) return true; 243: const baseCommand = parts[0]?.trim(); 244: if (!baseCommand) return true; 245: return !DISALLOWED_AUTO_BACKGROUND_COMMANDS.includes(baseCommand); 246: } 247: export function detectBlockedSleepPattern(command: string): string | null { 248: const parts = splitCommand_DEPRECATED(command); 249: if (parts.length === 0) return null; 250: const first = parts[0]?.trim() ?? ''; 251: // Bare `sleep N` or `sleep N.N` as the first subcommand. 252: // Float durations (sleep 0.5) are allowed — those are legit pacing, not polls. 253: const m = /^sleep\s+(\d+)\s*$/.exec(first); 254: if (!m) return null; 255: const secs = parseInt(m[1]!, 10); 256: if (secs < 2) return null; // sub-2s sleeps are fine (rate limiting, pacing) 257: // `sleep N` alone → "what are you waiting for?" 258: // `sleep N && check` → "use Monitor { command: check }" 259: const rest = parts.slice(1).join(' ').trim(); 260: return rest ? `sleep ${secs} followed by: ${rest}` : `standalone sleep ${secs}`; 261: } 262: /** 263: * Checks if a command contains tools that shouldn't run in sandbox 264: * This includes: 265: * - Dynamic config-based disabled commands and substrings (tengu_sandbox_disabled_commands) 266: * - User-configured commands from settings.json (sandbox.excludedCommands) 267: * 268: * User-configured commands support the same pattern syntax as permission rules: 269: * - Exact matches: "npm run lint" 270: * - Prefix patterns: "npm run test:*" 271: */ 272: type SimulatedSedEditResult = { 273: data: Out; 274: }; 275: type SimulatedSedEditContext = Pick<ToolUseContext, 'readFileState' | 'updateFileHistoryState'>; 276: async function applySedEdit(simulatedEdit: { 277: filePath: string; 278: newContent: string; 279: }, toolUseContext: SimulatedSedEditContext, parentMessage?: AssistantMessage): Promise<SimulatedSedEditResult> { 280: const { 281: filePath, 282: newContent 283: } = simulatedEdit; 284: const absoluteFilePath = expandPath(filePath); 285: const fs = getFsImplementation(); 286: const encoding = detectFileEncoding(absoluteFilePath); 287: let originalContent: string; 288: try { 289: originalContent = await fs.readFile(absoluteFilePath, { 290: encoding 291: }); 292: } catch (e) { 293: if (isENOENT(e)) { 294: return { 295: data: { 296: stdout: '', 297: stderr: `sed: ${filePath}: No such file or directory\nExit code 1`, 298: interrupted: false 299: } 300: }; 301: } 302: throw e; 303: } 304: // Track file history before making changes (for undo support) 305: if (fileHistoryEnabled() && parentMessage) { 306: await fileHistoryTrackEdit(toolUseContext.updateFileHistoryState, absoluteFilePath, parentMessage.uuid); 307: } 308: // Detect line endings and write new content 309: const endings = detectLineEndings(absoluteFilePath); 310: writeTextContent(absoluteFilePath, newContent, encoding, endings); 311: // Notify VS Code about the file change 312: notifyVscodeFileUpdated(absoluteFilePath, originalContent, newContent); 313: // Update read timestamp to invalidate stale writes 314: toolUseContext.readFileState.set(absoluteFilePath, { 315: content: newContent, 316: timestamp: getFileModificationTime(absoluteFilePath), 317: offset: undefined, 318: limit: undefined 319: }); 320: // Return success result matching sed output format (sed produces no output on success) 321: return { 322: data: { 323: stdout: '', 324: stderr: '', 325: interrupted: false 326: } 327: }; 328: } 329: export const BashTool = buildTool({ 330: name: BASH_TOOL_NAME, 331: searchHint: 'execute shell commands', 332: maxResultSizeChars: 30_000, 333: strict: true, 334: async description({ 335: description 336: }) { 337: return description || 'Run shell command'; 338: }, 339: async prompt() { 340: return getSimplePrompt(); 341: }, 342: isConcurrencySafe(input) { 343: return this.isReadOnly?.(input) ?? false; 344: }, 345: isReadOnly(input) { 346: const compoundCommandHasCd = commandHasAnyCd(input.command); 347: const result = checkReadOnlyConstraints(input, compoundCommandHasCd); 348: return result.behavior === 'allow'; 349: }, 350: toAutoClassifierInput(input) { 351: return input.command; 352: }, 353: async preparePermissionMatcher({ 354: command 355: }) { 356: const parsed = await parseForSecurity(command); 357: if (parsed.kind !== 'simple') { 358: return () => true; 359: } 360: const subcommands = parsed.commands.map(c => c.argv.join(' ')); 361: return pattern => { 362: const prefix = permissionRuleExtractPrefix(pattern); 363: return subcommands.some(cmd => { 364: if (prefix !== null) { 365: return cmd === prefix || cmd.startsWith(`${prefix} `); 366: } 367: return matchWildcardPattern(pattern, cmd); 368: }); 369: }; 370: }, 371: isSearchOrReadCommand(input) { 372: const parsed = inputSchema().safeParse(input); 373: if (!parsed.success) return { 374: isSearch: false, 375: isRead: false, 376: isList: false 377: }; 378: return isSearchOrReadBashCommand(parsed.data.command); 379: }, 380: get inputSchema(): InputSchema { 381: return inputSchema(); 382: }, 383: get outputSchema(): OutputSchema { 384: return outputSchema(); 385: }, 386: userFacingName(input) { 387: if (!input) { 388: return 'Bash'; 389: } 390: if (input.command) { 391: const sedInfo = parseSedEditCommand(input.command); 392: if (sedInfo) { 393: return fileEditUserFacingName({ 394: file_path: sedInfo.filePath, 395: old_string: 'x' 396: }); 397: } 398: } 399: return isEnvTruthy(process.env.CLAUDE_CODE_BASH_SANDBOX_SHOW_INDICATOR) && shouldUseSandbox(input) ? 'SandboxedBash' : 'Bash'; 400: }, 401: getToolUseSummary(input) { 402: if (!input?.command) { 403: return null; 404: } 405: const { 406: command, 407: description 408: } = input; 409: if (description) { 410: return description; 411: } 412: return truncate(command, TOOL_SUMMARY_MAX_LENGTH); 413: }, 414: getActivityDescription(input) { 415: if (!input?.command) { 416: return 'Running command'; 417: } 418: const desc = input.description ?? truncate(input.command, TOOL_SUMMARY_MAX_LENGTH); 419: return `Running ${desc}`; 420: }, 421: async validateInput(input: BashToolInput): Promise<ValidationResult> { 422: if (feature('MONITOR_TOOL') && !isBackgroundTasksDisabled && !input.run_in_background) { 423: const sleepPattern = detectBlockedSleepPattern(input.command); 424: if (sleepPattern !== null) { 425: return { 426: result: false, 427: message: `Blocked: ${sleepPattern}. Run blocking commands in the background with run_in_background: true — you'll get a completion notification when done. For streaming events (watching logs, polling APIs), use the Monitor tool. If you genuinely need a delay (rate limiting, deliberate pacing), keep it under 2 seconds.`, 428: errorCode: 10 429: }; 430: } 431: } 432: return { 433: result: true 434: }; 435: }, 436: async checkPermissions(input, context): Promise<PermissionResult> { 437: return bashToolHasPermission(input, context); 438: }, 439: renderToolUseMessage, 440: renderToolUseProgressMessage, 441: renderToolUseQueuedMessage, 442: renderToolResultMessage, 443: extractSearchText({ 444: stdout, 445: stderr 446: }) { 447: return stderr ? `${stdout}\n${stderr}` : stdout; 448: }, 449: mapToolResultToToolResultBlockParam({ 450: interrupted, 451: stdout, 452: stderr, 453: isImage, 454: backgroundTaskId, 455: backgroundedByUser, 456: assistantAutoBackgrounded, 457: structuredContent, 458: persistedOutputPath, 459: persistedOutputSize 460: }, toolUseID): ToolResultBlockParam { 461: if (structuredContent && structuredContent.length > 0) { 462: return { 463: tool_use_id: toolUseID, 464: type: 'tool_result', 465: content: structuredContent 466: }; 467: } 468: if (isImage) { 469: const block = buildImageToolResult(stdout, toolUseID); 470: if (block) return block; 471: } 472: let processedStdout = stdout; 473: if (stdout) { 474: processedStdout = stdout.replace(/^(\s*\n)+/, ''); 475: // Still trim the end as before 476: processedStdout = processedStdout.trimEnd(); 477: } 478: // For large output that was persisted to disk, build <persisted-output> 479: // message for the model. The UI never sees this — it uses data.stdout. 480: if (persistedOutputPath) { 481: const preview = generatePreview(processedStdout, PREVIEW_SIZE_BYTES); 482: processedStdout = buildLargeToolResultMessage({ 483: filepath: persistedOutputPath, 484: originalSize: persistedOutputSize ?? 0, 485: isJson: false, 486: preview: preview.preview, 487: hasMore: preview.hasMore 488: }); 489: } 490: let errorMessage = stderr.trim(); 491: if (interrupted) { 492: if (stderr) errorMessage += EOL; 493: errorMessage += '<error>Command was aborted before completion</error>'; 494: } 495: let backgroundInfo = ''; 496: if (backgroundTaskId) { 497: const outputPath = getTaskOutputPath(backgroundTaskId); 498: if (assistantAutoBackgrounded) { 499: backgroundInfo = `Command exceeded the assistant-mode blocking budget (${ASSISTANT_BLOCKING_BUDGET_MS / 1000}s) and was moved to the background with ID: ${backgroundTaskId}. It is still running — you will be notified when it completes. Output is being written to: ${outputPath}. In assistant mode, delegate long-running work to a subagent or use run_in_background to keep this conversation responsive.`; 500: } else if (backgroundedByUser) { 501: backgroundInfo = `Command was manually backgrounded by user with ID: ${backgroundTaskId}. Output is being written to: ${outputPath}`; 502: } else { 503: backgroundInfo = `Command running in background with ID: ${backgroundTaskId}. Output is being written to: ${outputPath}`; 504: } 505: } 506: return { 507: tool_use_id: toolUseID, 508: type: 'tool_result', 509: content: [processedStdout, errorMessage, backgroundInfo].filter(Boolean).join('\n'), 510: is_error: interrupted 511: }; 512: }, 513: async call(input: BashToolInput, toolUseContext, _canUseTool?: CanUseToolFn, parentMessage?: AssistantMessage, onProgress?: ToolCallProgress<BashProgress>) { 514: if (input._simulatedSedEdit) { 515: return applySedEdit(input._simulatedSedEdit, toolUseContext, parentMessage); 516: } 517: const { 518: abortController, 519: getAppState, 520: setAppState, 521: setToolJSX 522: } = toolUseContext; 523: const stdoutAccumulator = new EndTruncatingAccumulator(); 524: let stderrForShellReset = ''; 525: let interpretationResult: ReturnType<typeof interpretCommandResult> | undefined; 526: let progressCounter = 0; 527: let wasInterrupted = false; 528: let result: ExecResult; 529: const isMainThread = !toolUseContext.agentId; 530: const preventCwdChanges = !isMainThread; 531: try { 532: // Use the new async generator version of runShellCommand 533: const commandGenerator = runShellCommand({ 534: input, 535: abortController, 536: // Use the always-shared task channel so async agents' background 537: setAppState: toolUseContext.setAppStateForTasks ?? setAppState, 538: setToolJSX, 539: preventCwdChanges, 540: isMainThread, 541: toolUseId: toolUseContext.toolUseId, 542: agentId: toolUseContext.agentId 543: }); 544: let generatorResult; 545: do { 546: generatorResult = await commandGenerator.next(); 547: if (!generatorResult.done && onProgress) { 548: const progress = generatorResult.value; 549: onProgress({ 550: toolUseID: `bash-progress-${progressCounter++}`, 551: data: { 552: type: 'bash_progress', 553: output: progress.output, 554: fullOutput: progress.fullOutput, 555: elapsedTimeSeconds: progress.elapsedTimeSeconds, 556: totalLines: progress.totalLines, 557: totalBytes: progress.totalBytes, 558: taskId: progress.taskId, 559: timeoutMs: progress.timeoutMs 560: } 561: }); 562: } 563: } while (!generatorResult.done); 564: result = generatorResult.value; 565: trackGitOperations(input.command, result.code, result.stdout); 566: const isInterrupt = result.interrupted && abortController.signal.reason === 'interrupt'; 567: stdoutAccumulator.append((result.stdout || '').trimEnd() + EOL); 568: // Interpret the command result using semantic rules 569: interpretationResult = interpretCommandResult(input.command, result.code, result.stdout || '', ''); 570: // Check for git index.lock error (stderr is in stdout now) 571: if (result.stdout && result.stdout.includes(".git/index.lock': File exists")) { 572: logEvent('tengu_git_index_lock_error', {}); 573: } 574: if (interpretationResult.isError && !isInterrupt) { 575: if (result.code !== 0) { 576: stdoutAccumulator.append(`Exit code ${result.code}`); 577: } 578: } 579: if (!preventCwdChanges) { 580: const appState = getAppState(); 581: if (resetCwdIfOutsideProject(appState.toolPermissionContext)) { 582: stderrForShellReset = stdErrAppendShellResetMessage(''); 583: } 584: } 585: // Annotate output with sandbox violations if any (stderr is in stdout) 586: const outputWithSbFailures = SandboxManager.annotateStderrWithSandboxFailures(input.command, result.stdout || ''); 587: if (result.preSpawnError) { 588: throw new Error(result.preSpawnError); 589: } 590: if (interpretationResult.isError && !isInterrupt) { 591: // stderr is merged into stdout (merged fd); outputWithSbFailures 592: // already has the full output. Pass '' for stdout to avoid 593: // duplication in getErrorParts() and processBashCommand. 594: throw new ShellError('', outputWithSbFailures, result.code, result.interrupted); 595: } 596: wasInterrupted = result.interrupted; 597: } finally { 598: if (setToolJSX) setToolJSX(null); 599: } 600: // Get final string from accumulator 601: const stdout = stdoutAccumulator.toString(); 602: // Large output: the file on disk has more than getMaxOutputLength() bytes. 603: // stdout already contains the first chunk (from getStdout()). Copy the 604: // output file to the tool-results dir so the model can read it via 605: // FileRead. If > 64 MB, truncate after copying. 606: const MAX_PERSISTED_SIZE = 64 * 1024 * 1024; 607: let persistedOutputPath: string | undefined; 608: let persistedOutputSize: number | undefined; 609: if (result.outputFilePath && result.outputTaskId) { 610: try { 611: const fileStat = await fsStat(result.outputFilePath); 612: persistedOutputSize = fileStat.size; 613: await ensureToolResultsDir(); 614: const dest = getToolResultPath(result.outputTaskId, false); 615: if (fileStat.size > MAX_PERSISTED_SIZE) { 616: await fsTruncate(result.outputFilePath, MAX_PERSISTED_SIZE); 617: } 618: try { 619: await link(result.outputFilePath, dest); 620: } catch { 621: await copyFile(result.outputFilePath, dest); 622: } 623: persistedOutputPath = dest; 624: } catch { 625: // File may already be gone — stdout preview is sufficient 626: } 627: } 628: const commandType = input.command.split(' ')[0]; 629: logEvent('tengu_bash_tool_command_executed', { 630: command_type: commandType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 631: stdout_length: stdout.length, 632: stderr_length: 0, 633: exit_code: result.code, 634: interrupted: wasInterrupted 635: }); 636: const codeIndexingTool = detectCodeIndexingFromCommand(input.command); 637: if (codeIndexingTool) { 638: logEvent('tengu_code_indexing_tool_used', { 639: tool: codeIndexingTool as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 640: source: 'cli' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 641: success: result.code === 0 642: }); 643: } 644: let strippedStdout = stripEmptyLines(stdout); 645: const extracted = extractClaudeCodeHints(strippedStdout, input.command); 646: strippedStdout = extracted.stripped; 647: if (isMainThread && extracted.hints.length > 0) { 648: for (const hint of extracted.hints) maybeRecordPluginHint(hint); 649: } 650: let isImage = isImageOutput(strippedStdout); 651: let compressedStdout = strippedStdout; 652: if (isImage) { 653: const resized = await resizeShellImageOutput(strippedStdout, result.outputFilePath, persistedOutputSize); 654: if (resized) { 655: compressedStdout = resized; 656: } else { 657: isImage = false; 658: } 659: } 660: const data: Out = { 661: stdout: compressedStdout, 662: stderr: stderrForShellReset, 663: interrupted: wasInterrupted, 664: isImage, 665: returnCodeInterpretation: interpretationResult?.message, 666: noOutputExpected: isSilentBashCommand(input.command), 667: backgroundTaskId: result.backgroundTaskId, 668: backgroundedByUser: result.backgroundedByUser, 669: assistantAutoBackgrounded: result.assistantAutoBackgrounded, 670: dangerouslyDisableSandbox: 'dangerouslyDisableSandbox' in input ? input.dangerouslyDisableSandbox as boolean | undefined : undefined, 671: persistedOutputPath, 672: persistedOutputSize 673: }; 674: return { 675: data 676: }; 677: }, 678: renderToolUseErrorMessage, 679: isResultTruncated(output: Out): boolean { 680: return isOutputLineTruncated(output.stdout) || isOutputLineTruncated(output.stderr); 681: } 682: } satisfies ToolDef<InputSchema, Out, BashProgress>); 683: async function* runShellCommand({ 684: input, 685: abortController, 686: setAppState, 687: setToolJSX, 688: preventCwdChanges, 689: isMainThread, 690: toolUseId, 691: agentId 692: }: { 693: input: BashToolInput; 694: abortController: AbortController; 695: setAppState: (f: (prev: AppState) => AppState) => void; 696: setToolJSX?: SetToolJSXFn; 697: preventCwdChanges?: boolean; 698: isMainThread?: boolean; 699: toolUseId?: string; 700: agentId?: AgentId; 701: }): AsyncGenerator<{ 702: type: 'progress'; 703: output: string; 704: fullOutput: string; 705: elapsedTimeSeconds: number; 706: totalLines: number; 707: totalBytes?: number; 708: taskId?: string; 709: timeoutMs?: number; 710: }, ExecResult, void> { 711: const { 712: command, 713: description, 714: timeout, 715: run_in_background 716: } = input; 717: const timeoutMs = timeout || getDefaultTimeoutMs(); 718: let fullOutput = ''; 719: let lastProgressOutput = ''; 720: let lastTotalLines = 0; 721: let lastTotalBytes = 0; 722: let backgroundShellId: string | undefined = undefined; 723: let assistantAutoBackgrounded = false; 724: // Progress signal: resolved by onProgress callback from the shared poller, 725: // waking the generator to yield a progress update. 726: let resolveProgress: (() => void) | null = null; 727: function createProgressSignal(): Promise<null> { 728: return new Promise<null>(resolve => { 729: resolveProgress = () => resolve(null); 730: }); 731: } 732: // Determine if auto-backgrounding should be enabled 733: // Only enable for commands that are allowed to be auto-backgrounded 734: // and when background tasks are not disabled 735: const shouldAutoBackground = !isBackgroundTasksDisabled && isAutobackgroundingAllowed(command); 736: const shellCommand = await exec(command, abortController.signal, 'bash', { 737: timeout: timeoutMs, 738: onProgress(lastLines, allLines, totalLines, totalBytes, isIncomplete) { 739: lastProgressOutput = lastLines; 740: fullOutput = allLines; 741: lastTotalLines = totalLines; 742: lastTotalBytes = isIncomplete ? totalBytes : 0; 743: const resolve = resolveProgress; 744: if (resolve) { 745: resolveProgress = null; 746: resolve(); 747: } 748: }, 749: preventCwdChanges, 750: shouldUseSandbox: shouldUseSandbox(input), 751: shouldAutoBackground 752: }); 753: const resultPromise = shellCommand.result; 754: async function spawnBackgroundTask(): Promise<string> { 755: const handle = await spawnShellTask({ 756: command, 757: description: description || command, 758: shellCommand, 759: toolUseId, 760: agentId 761: }, { 762: abortController, 763: getAppState: () => { 764: throw new Error('getAppState not available in runShellCommand context'); 765: }, 766: setAppState 767: }); 768: return handle.taskId; 769: } 770: function startBackgrounding(eventName: string, backgroundFn?: (shellId: string) => void): void { 771: if (foregroundTaskId) { 772: if (!backgroundExistingForegroundTask(foregroundTaskId, shellCommand, description || command, setAppState, toolUseId)) { 773: return; 774: } 775: backgroundShellId = foregroundTaskId; 776: logEvent(eventName, { 777: command_type: getCommandTypeForLogging(command) 778: }); 779: backgroundFn?.(foregroundTaskId); 780: return; 781: } 782: void spawnBackgroundTask().then(shellId => { 783: backgroundShellId = shellId; 784: const resolve = resolveProgress; 785: if (resolve) { 786: resolveProgress = null; 787: resolve(); 788: } 789: logEvent(eventName, { 790: command_type: getCommandTypeForLogging(command) 791: }); 792: if (backgroundFn) { 793: backgroundFn(shellId); 794: } 795: }); 796: } 797: if (shellCommand.onTimeout && shouldAutoBackground) { 798: shellCommand.onTimeout(backgroundFn => { 799: startBackgrounding('tengu_bash_command_timeout_backgrounded', backgroundFn); 800: }); 801: } 802: if (feature('KAIROS') && getKairosActive() && isMainThread && !isBackgroundTasksDisabled && run_in_background !== true) { 803: setTimeout(() => { 804: if (shellCommand.status === 'running' && backgroundShellId === undefined) { 805: assistantAutoBackgrounded = true; 806: startBackgrounding('tengu_bash_command_assistant_auto_backgrounded'); 807: } 808: }, ASSISTANT_BLOCKING_BUDGET_MS).unref(); 809: } 810: if (run_in_background === true && !isBackgroundTasksDisabled) { 811: const shellId = await spawnBackgroundTask(); 812: logEvent('tengu_bash_command_explicitly_backgrounded', { 813: command_type: getCommandTypeForLogging(command) 814: }); 815: return { 816: stdout: '', 817: stderr: '', 818: code: 0, 819: interrupted: false, 820: backgroundTaskId: shellId 821: }; 822: } 823: // Wait for the initial threshold before showing progress 824: const startTime = Date.now(); 825: let foregroundTaskId: string | undefined = undefined; 826: { 827: const initialResult = await Promise.race([resultPromise, new Promise<null>(resolve => { 828: const t = setTimeout((r: (v: null) => void) => r(null), PROGRESS_THRESHOLD_MS, resolve); 829: t.unref(); 830: })]); 831: if (initialResult !== null) { 832: shellCommand.cleanup(); 833: return initialResult; 834: } 835: if (backgroundShellId) { 836: return { 837: stdout: '', 838: stderr: '', 839: code: 0, 840: interrupted: false, 841: backgroundTaskId: backgroundShellId, 842: assistantAutoBackgrounded 843: }; 844: } 845: } 846: // Start polling the output file for progress. The poller's #tick calls 847: TaskOutput.startPolling(shellCommand.taskOutput.taskId); 848: try { 849: while (true) { 850: const progressSignal = createProgressSignal(); 851: const result = await Promise.race([resultPromise, progressSignal]); 852: if (result !== null) { 853: if (result.backgroundTaskId !== undefined) { 854: markTaskNotified(result.backgroundTaskId, setAppState); 855: const fixedResult: ExecResult = { 856: ...result, 857: backgroundTaskId: undefined 858: }; 859: const { 860: taskOutput 861: } = shellCommand; 862: if (taskOutput.stdoutToFile && !taskOutput.outputFileRedundant) { 863: fixedResult.outputFilePath = taskOutput.path; 864: fixedResult.outputFileSize = taskOutput.outputFileSize; 865: fixedResult.outputTaskId = taskOutput.taskId; 866: } 867: shellCommand.cleanup(); 868: return fixedResult; 869: } 870: if (foregroundTaskId) { 871: unregisterForeground(foregroundTaskId, setAppState); 872: } 873: shellCommand.cleanup(); 874: return result; 875: } 876: if (backgroundShellId) { 877: return { 878: stdout: '', 879: stderr: '', 880: code: 0, 881: interrupted: false, 882: backgroundTaskId: backgroundShellId, 883: assistantAutoBackgrounded 884: }; 885: } 886: // Check if this foreground task was backgrounded via backgroundAll() 887: if (foregroundTaskId) { 888: // shellCommand.status becomes 'backgrounded' when background() is called 889: if (shellCommand.status === 'backgrounded') { 890: return { 891: stdout: '', 892: stderr: '', 893: code: 0, 894: interrupted: false, 895: backgroundTaskId: foregroundTaskId, 896: backgroundedByUser: true 897: }; 898: } 899: } 900: // Time for a progress update 901: const elapsed = Date.now() - startTime; 902: const elapsedSeconds = Math.floor(elapsed / 1000); 903: // Show minimal backgrounding UI if available 904: // Skip if background tasks are disabled 905: if (!isBackgroundTasksDisabled && backgroundShellId === undefined && elapsedSeconds >= PROGRESS_THRESHOLD_MS / 1000 && setToolJSX) { 906: // Register this command as a foreground task so it can be backgrounded via Ctrl+B 907: if (!foregroundTaskId) { 908: foregroundTaskId = registerForeground({ 909: command, 910: description: description || command, 911: shellCommand, 912: agentId 913: }, setAppState, toolUseId); 914: } 915: setToolJSX({ 916: jsx: <BackgroundHint />, 917: shouldHidePromptInput: false, 918: shouldContinueAnimation: true, 919: showSpinner: true 920: }); 921: } 922: yield { 923: type: 'progress', 924: fullOutput, 925: output: lastProgressOutput, 926: elapsedTimeSeconds: elapsedSeconds, 927: totalLines: lastTotalLines, 928: totalBytes: lastTotalBytes, 929: taskId: shellCommand.taskOutput.taskId, 930: ...(timeout ? { 931: timeoutMs 932: } : undefined) 933: }; 934: } 935: } finally { 936: TaskOutput.stopPolling(shellCommand.taskOutput.taskId); 937: } 938: }

File: src/tools/BashTool/BashToolResultMessage.tsx

typescript 1: import { c as _c } from "react/compiler-runtime"; 2: import React from 'react'; 3: import { removeSandboxViolationTags } from 'src/utils/sandbox/sandbox-ui-utils.js'; 4: import { KeyboardShortcutHint } from '../../components/design-system/KeyboardShortcutHint.js'; 5: import { MessageResponse } from '../../components/MessageResponse.js'; 6: import { OutputLine } from '../../components/shell/OutputLine.js'; 7: import { ShellTimeDisplay } from '../../components/shell/ShellTimeDisplay.js'; 8: import { Box, Text } from '../../ink.js'; 9: import type { Out as BashOut } from './BashTool.js'; 10: type Props = { 11: content: Omit<BashOut, 'interrupted'>; 12: verbose: boolean; 13: timeoutMs?: number; 14: }; 15: const SHELL_CWD_RESET_PATTERN = /(?:^|\n)(Shell cwd was reset to .+)$/; 16: function extractSandboxViolations(stderr: string): { 17: cleanedStderr: string; 18: } { 19: const violationsMatch = stderr.match(/<sandbox_violations>([\s\S]*?)<\/sandbox_violations>/); 20: if (!violationsMatch) { 21: return { 22: cleanedStderr: stderr 23: }; 24: } 25: const cleanedStderr = removeSandboxViolationTags(stderr).trim(); 26: return { 27: cleanedStderr 28: }; 29: } 30: function extractCwdResetWarning(stderr: string): { 31: cleanedStderr: string; 32: cwdResetWarning: string | null; 33: } { 34: const match = stderr.match(SHELL_CWD_RESET_PATTERN); 35: if (!match) { 36: return { 37: cleanedStderr: stderr, 38: cwdResetWarning: null 39: }; 40: } 41: const cwdResetWarning = match[1] ?? null; 42: const cleanedStderr = stderr.replace(SHELL_CWD_RESET_PATTERN, '').trim(); 43: return { 44: cleanedStderr, 45: cwdResetWarning 46: }; 47: } 48: export default function BashToolResultMessage(t0) { 49: const $ = _c(34); 50: const { 51: content: t1, 52: verbose, 53: timeoutMs 54: } = t0; 55: const { 56: stdout: t2, 57: stderr: t3, 58: isImage, 59: returnCodeInterpretation, 60: noOutputExpected, 61: backgroundTaskId 62: } = t1; 63: const stdout = t2 === undefined ? "" : t2; 64: const stdErrWithViolations = t3 === undefined ? "" : t3; 65: let T0; 66: let cwdResetWarning; 67: let stderr; 68: let t4; 69: let t5; 70: let t6; 71: let t7; 72: if ($[0] !== isImage || $[1] !== stdErrWithViolations || $[2] !== stdout || $[3] !== verbose) { 73: t7 = Symbol.for("react.early_return_sentinel"); 74: bb0: { 75: const { 76: cleanedStderr: stderrWithoutViolations 77: } = extractSandboxViolations(stdErrWithViolations); 78: ({ 79: cleanedStderr: stderr, 80: cwdResetWarning 81: } = extractCwdResetWarning(stderrWithoutViolations)); 82: if (isImage) { 83: let t8; 84: if ($[11] === Symbol.for("react.memo_cache_sentinel")) { 85: t8 = <MessageResponse height={1}><Text dimColor={true}>[Image data detected and sent to Claude]</Text></MessageResponse>; 86: $[11] = t8; 87: } else { 88: t8 = $[11]; 89: } 90: t7 = t8; 91: break bb0; 92: } 93: T0 = Box; 94: t4 = "column"; 95: if ($[12] !== stdout || $[13] !== verbose) { 96: t5 = stdout !== "" ? <OutputLine content={stdout} verbose={verbose} /> : null; 97: $[12] = stdout; 98: $[13] = verbose; 99: $[14] = t5; 100: } else { 101: t5 = $[14]; 102: } 103: t6 = stderr.trim() !== "" ? <OutputLine content={stderr} verbose={verbose} isError={true} /> : null; 104: } 105: $[0] = isImage; 106: $[1] = stdErrWithViolations; 107: $[2] = stdout; 108: $[3] = verbose; 109: $[4] = T0; 110: $[5] = cwdResetWarning; 111: $[6] = stderr; 112: $[7] = t4; 113: $[8] = t5; 114: $[9] = t6; 115: $[10] = t7; 116: } else { 117: T0 = $[4]; 118: cwdResetWarning = $[5]; 119: stderr = $[6]; 120: t4 = $[7]; 121: t5 = $[8]; 122: t6 = $[9]; 123: t7 = $[10]; 124: } 125: if (t7 !== Symbol.for("react.early_return_sentinel")) { 126: return t7; 127: } 128: let t8; 129: if ($[15] !== cwdResetWarning) { 130: t8 = cwdResetWarning ? <MessageResponse><Text dimColor={true}>{cwdResetWarning}</Text></MessageResponse> : null; 131: $[15] = cwdResetWarning; 132: $[16] = t8; 133: } else { 134: t8 = $[16]; 135: } 136: let t9; 137: if ($[17] !== backgroundTaskId || $[18] !== cwdResetWarning || $[19] !== noOutputExpected || $[20] !== returnCodeInterpretation || $[21] !== stderr || $[22] !== stdout) { 138: t9 = stdout === "" && stderr.trim() === "" && !cwdResetWarning ? <MessageResponse height={1}><Text dimColor={true}>{backgroundTaskId ? <>Running in the background{" "}<KeyboardShortcutHint shortcut={"\u2193"} action="manage" parens={true} /></> : returnCodeInterpretation || (noOutputExpected ? "Done" : "(No output)")}</Text></MessageResponse> : null; 139: $[17] = backgroundTaskId; 140: $[18] = cwdResetWarning; 141: $[19] = noOutputExpected; 142: $[20] = returnCodeInterpretation; 143: $[21] = stderr; 144: $[22] = stdout; 145: $[23] = t9; 146: } else { 147: t9 = $[23]; 148: } 149: let t10; 150: if ($[24] !== timeoutMs) { 151: t10 = timeoutMs && <MessageResponse><ShellTimeDisplay timeoutMs={timeoutMs} /></MessageResponse>; 152: $[24] = timeoutMs; 153: $[25] = t10; 154: } else { 155: t10 = $[25]; 156: } 157: let t11; 158: if ($[26] !== T0 || $[27] !== t10 || $[28] !== t4 || $[29] !== t5 || $[30] !== t6 || $[31] !== t8 || $[32] !== t9) { 159: t11 = <T0 flexDirection={t4}>{t5}{t6}{t8}{t9}{t10}</T0>; 160: $[26] = T0; 161: $[27] = t10; 162: $[28] = t4; 163: $[29] = t5; 164: $[30] = t6; 165: $[31] = t8; 166: $[32] = t9; 167: $[33] = t11; 168: } else { 169: t11 = $[33]; 170: } 171: return t11; 172: }

File: src/tools/BashTool/commandSemantics.ts

typescript 1: import { splitCommand_DEPRECATED } from '../../utils/bash/commands.js' 2: export type CommandSemantic = ( 3: exitCode: number, 4: stdout: string, 5: stderr: string, 6: ) => { 7: isError: boolean 8: message?: string 9: } 10: const DEFAULT_SEMANTIC: CommandSemantic = (exitCode, _stdout, _stderr) => ({ 11: isError: exitCode !== 0, 12: message: 13: exitCode !== 0 ? `Command failed with exit code ${exitCode}` : undefined, 14: }) 15: const COMMAND_SEMANTICS: Map<string, CommandSemantic> = new Map([ 16: [ 17: 'grep', 18: (exitCode, _stdout, _stderr) => ({ 19: isError: exitCode >= 2, 20: message: exitCode === 1 ? 'No matches found' : undefined, 21: }), 22: ], 23: [ 24: 'rg', 25: (exitCode, _stdout, _stderr) => ({ 26: isError: exitCode >= 2, 27: message: exitCode === 1 ? 'No matches found' : undefined, 28: }), 29: ], 30: [ 31: 'find', 32: (exitCode, _stdout, _stderr) => ({ 33: isError: exitCode >= 2, 34: message: 35: exitCode === 1 ? 'Some directories were inaccessible' : undefined, 36: }), 37: ], 38: [ 39: 'diff', 40: (exitCode, _stdout, _stderr) => ({ 41: isError: exitCode >= 2, 42: message: exitCode === 1 ? 'Files differ' : undefined, 43: }), 44: ], 45: [ 46: 'test', 47: (exitCode, _stdout, _stderr) => ({ 48: isError: exitCode >= 2, 49: message: exitCode === 1 ? 'Condition is false' : undefined, 50: }), 51: ], 52: [ 53: '[', 54: (exitCode, _stdout, _stderr) => ({ 55: isError: exitCode >= 2, 56: message: exitCode === 1 ? 'Condition is false' : undefined, 57: }), 58: ], 59: ]) 60: function getCommandSemantic(command: string): CommandSemantic { 61: const baseCommand = heuristicallyExtractBaseCommand(command) 62: const semantic = COMMAND_SEMANTICS.get(baseCommand) 63: return semantic !== undefined ? semantic : DEFAULT_SEMANTIC 64: } 65: function extractBaseCommand(command: string): string { 66: return command.trim().split(/\s+/)[0] || '' 67: } 68: /** 69: * Extract the primary command from a complex command line; 70: * May get it super wrong - don't depend on this for security 71: */ 72: function heuristicallyExtractBaseCommand(command: string): string { 73: const segments = splitCommand_DEPRECATED(command) 74: const lastCommand = segments[segments.length - 1] || command 75: return extractBaseCommand(lastCommand) 76: } 77: export function interpretCommandResult( 78: command: string, 79: exitCode: number, 80: stdout: string, 81: stderr: string, 82: ): { 83: isError: boolean 84: message?: string 85: } { 86: const semantic = getCommandSemantic(command) 87: const result = semantic(exitCode, stdout, stderr) 88: return { 89: isError: result.isError, 90: message: result.message, 91: } 92: }

File: src/tools/BashTool/commentLabel.ts

typescript 1: export function extractBashCommentLabel(command: string): string | undefined { 2: const nl = command.indexOf('\n') 3: const firstLine = (nl === -1 ? command : command.slice(0, nl)).trim() 4: if (!firstLine.startsWith('#') || firstLine.startsWith('#!')) return undefined 5: return firstLine.replace(/^#+\s*/, '') || undefined 6: }

File: src/tools/BashTool/destructiveCommandWarning.ts

typescript 1: type DestructivePattern = { 2: pattern: RegExp 3: warning: string 4: } 5: const DESTRUCTIVE_PATTERNS: DestructivePattern[] = [ 6: { 7: pattern: /\bgit\s+reset\s+--hard\b/, 8: warning: 'Note: may discard uncommitted changes', 9: }, 10: { 11: pattern: /\bgit\s+push\b[^;&|\n]*[ \t](--force|--force-with-lease|-f)\b/, 12: warning: 'Note: may overwrite remote history', 13: }, 14: { 15: pattern: 16: /\bgit\s+clean\b(?![^;&|\n]*(?:-[a-zA-Z]*n|--dry-run))[^;&|\n]*-[a-zA-Z]*f/, 17: warning: 'Note: may permanently delete untracked files', 18: }, 19: { 20: pattern: /\bgit\s+checkout\s+(--\s+)?\.[ \t]*($|[;&|\n])/, 21: warning: 'Note: may discard all working tree changes', 22: }, 23: { 24: pattern: /\bgit\s+restore\s+(--\s+)?\.[ \t]*($|[;&|\n])/, 25: warning: 'Note: may discard all working tree changes', 26: }, 27: { 28: pattern: /\bgit\s+stash[ \t]+(drop|clear)\b/, 29: warning: 'Note: may permanently remove stashed changes', 30: }, 31: { 32: pattern: 33: /\bgit\s+branch\s+(-D[ \t]|--delete\s+--force|--force\s+--delete)\b/, 34: warning: 'Note: may force-delete a branch', 35: }, 36: { 37: pattern: /\bgit\s+(commit|push|merge)\b[^;&|\n]*--no-verify\b/, 38: warning: 'Note: may skip safety hooks', 39: }, 40: { 41: pattern: /\bgit\s+commit\b[^;&|\n]*--amend\b/, 42: warning: 'Note: may rewrite the last commit', 43: }, 44: { 45: pattern: 46: /(^|[;&|\n]\s*)rm\s+-[a-zA-Z]*[rR][a-zA-Z]*f|(^|[;&|\n]\s*)rm\s+-[a-zA-Z]*f[a-zA-Z]*[rR]/, 47: warning: 'Note: may recursively force-remove files', 48: }, 49: { 50: pattern: /(^|[;&|\n]\s*)rm\s+-[a-zA-Z]*[rR]/, 51: warning: 'Note: may recursively remove files', 52: }, 53: { 54: pattern: /(^|[;&|\n]\s*)rm\s+-[a-zA-Z]*f/, 55: warning: 'Note: may force-remove files', 56: }, 57: { 58: pattern: /\b(DROP|TRUNCATE)\s+(TABLE|DATABASE|SCHEMA)\b/i, 59: warning: 'Note: may drop or truncate database objects', 60: }, 61: { 62: pattern: /\bDELETE\s+FROM\s+\w+[ \t]*(;|"|'|\n|$)/i, 63: warning: 'Note: may delete all rows from a database table', 64: }, 65: { 66: pattern: /\bkubectl\s+delete\b/, 67: warning: 'Note: may delete Kubernetes resources', 68: }, 69: { 70: pattern: /\bterraform\s+destroy\b/, 71: warning: 'Note: may destroy Terraform infrastructure', 72: }, 73: ] 74: export function getDestructiveCommandWarning(command: string): string | null { 75: for (const { pattern, warning } of DESTRUCTIVE_PATTERNS) { 76: if (pattern.test(command)) { 77: return warning 78: } 79: } 80: return null 81: }

File: src/tools/BashTool/modeValidation.ts

typescript 1: import type { z } from 'zod/v4' 2: import type { ToolPermissionContext } from '../../Tool.js' 3: import { splitCommand_DEPRECATED } from '../../utils/bash/commands.js' 4: import type { PermissionResult } from '../../utils/permissions/PermissionResult.js' 5: import type { BashTool } from './BashTool.js' 6: const ACCEPT_EDITS_ALLOWED_COMMANDS = [ 7: 'mkdir', 8: 'touch', 9: 'rm', 10: 'rmdir', 11: 'mv', 12: 'cp', 13: 'sed', 14: ] as const 15: type FilesystemCommand = (typeof ACCEPT_EDITS_ALLOWED_COMMANDS)[number] 16: function isFilesystemCommand(command: string): command is FilesystemCommand { 17: return ACCEPT_EDITS_ALLOWED_COMMANDS.includes(command as FilesystemCommand) 18: } 19: function validateCommandForMode( 20: cmd: string, 21: toolPermissionContext: ToolPermissionContext, 22: ): PermissionResult { 23: const trimmedCmd = cmd.trim() 24: const [baseCmd] = trimmedCmd.split(/\s+/) 25: if (!baseCmd) { 26: return { 27: behavior: 'passthrough', 28: message: 'Base command not found', 29: } 30: } 31: if ( 32: toolPermissionContext.mode === 'acceptEdits' && 33: isFilesystemCommand(baseCmd) 34: ) { 35: return { 36: behavior: 'allow', 37: updatedInput: { command: cmd }, 38: decisionReason: { 39: type: 'mode', 40: mode: 'acceptEdits', 41: }, 42: } 43: } 44: return { 45: behavior: 'passthrough', 46: message: `No mode-specific handling for '${baseCmd}' in ${toolPermissionContext.mode} mode`, 47: } 48: } 49: export function checkPermissionMode( 50: input: z.infer<typeof BashTool.inputSchema>, 51: toolPermissionContext: ToolPermissionContext, 52: ): PermissionResult { 53: if (toolPermissionContext.mode === 'bypassPermissions') { 54: return { 55: behavior: 'passthrough', 56: message: 'Bypass mode is handled in main permission flow', 57: } 58: } 59: if (toolPermissionContext.mode === 'dontAsk') { 60: return { 61: behavior: 'passthrough', 62: message: 'DontAsk mode is handled in main permission flow', 63: } 64: } 65: const commands = splitCommand_DEPRECATED(input.command) 66: for (const cmd of commands) { 67: const result = validateCommandForMode(cmd, toolPermissionContext) 68: if (result.behavior !== 'passthrough') { 69: return result 70: } 71: } 72: return { 73: behavior: 'passthrough', 74: message: 'No mode-specific validation required', 75: } 76: } 77: export function getAutoAllowedCommands( 78: mode: ToolPermissionContext['mode'], 79: ): readonly string[] { 80: return mode === 'acceptEdits' ? ACCEPT_EDITS_ALLOWED_COMMANDS : [] 81: }

File: src/tools/BashTool/pathValidation.ts

typescript 1: import { homedir } from 'os' 2: import { isAbsolute, resolve } from 'path' 3: import type { z } from 'zod/v4' 4: import type { ToolPermissionContext } from '../../Tool.js' 5: import type { Redirect, SimpleCommand } from '../../utils/bash/ast.js' 6: import { 7: extractOutputRedirections, 8: splitCommand_DEPRECATED, 9: } from '../../utils/bash/commands.js' 10: import { tryParseShellCommand } from '../../utils/bash/shellQuote.js' 11: import { getDirectoryForPath } from '../../utils/path.js' 12: import { allWorkingDirectories } from '../../utils/permissions/filesystem.js' 13: import type { PermissionResult } from '../../utils/permissions/PermissionResult.js' 14: import { createReadRuleSuggestion } from '../../utils/permissions/PermissionUpdate.js' 15: import type { PermissionUpdate } from '../../utils/permissions/PermissionUpdateSchema.js' 16: import { 17: expandTilde, 18: type FileOperationType, 19: formatDirectoryList, 20: isDangerousRemovalPath, 21: validatePath, 22: } from '../../utils/permissions/pathValidation.js' 23: import type { BashTool } from './BashTool.js' 24: import { stripSafeWrappers } from './bashPermissions.js' 25: import { sedCommandIsAllowedByAllowlist } from './sedValidation.js' 26: export type PathCommand = 27: | 'cd' 28: | 'ls' 29: | 'find' 30: | 'mkdir' 31: | 'touch' 32: | 'rm' 33: | 'rmdir' 34: | 'mv' 35: | 'cp' 36: | 'cat' 37: | 'head' 38: | 'tail' 39: | 'sort' 40: | 'uniq' 41: | 'wc' 42: | 'cut' 43: | 'paste' 44: | 'column' 45: | 'tr' 46: | 'file' 47: | 'stat' 48: | 'diff' 49: | 'awk' 50: | 'strings' 51: | 'hexdump' 52: | 'od' 53: | 'base64' 54: | 'nl' 55: | 'grep' 56: | 'rg' 57: | 'sed' 58: | 'git' 59: | 'jq' 60: | 'sha256sum' 61: | 'sha1sum' 62: | 'md5sum' 63: function checkDangerousRemovalPaths( 64: command: 'rm' | 'rmdir', 65: args: string[], 66: cwd: string, 67: ): PermissionResult { 68: const extractor = PATH_EXTRACTORS[command] 69: const paths = extractor(args) 70: for (const path of paths) { 71: const cleanPath = expandTilde(path.replace(/^['"]|['"]$/g, '')) 72: const absolutePath = isAbsolute(cleanPath) 73: ? cleanPath 74: : resolve(cwd, cleanPath) 75: // Check if this is a dangerous path (using the non-symlink-resolved path) 76: if (isDangerousRemovalPath(absolutePath)) { 77: return { 78: behavior: 'ask', 79: message: `Dangerous ${command} operation detected: '${absolutePath}'\n\nThis command would remove a critical system directory. This requires explicit approval and cannot be auto-allowed by permission rules.`, 80: decisionReason: { 81: type: 'other', 82: reason: `Dangerous ${command} operation on critical path: ${absolutePath}`, 83: }, 84: // Don't provide suggestions - we don't want to encourage saving dangerous commands 85: suggestions: [], 86: } 87: } 88: } 89: // No dangerous paths found 90: return { 91: behavior: 'passthrough', 92: message: `No dangerous removals detected for ${command} command`, 93: } 94: } 95: /** 96: * SECURITY: Extract positional (non-flag) arguments, correctly handling the 97: * POSIX `--` end-of-options delimiter. 98: * 99: * Most commands (rm, cat, touch, etc.) stop parsing options at `--` and treat 100: * ALL subsequent arguments as positional, even if they start with `-`. Naive 101: * `!arg.startsWith('-')` filtering drops these, causing path validation to be 102: * silently skipped for attack payloads like: 103: * 104: * rm -- -/../.claude/settings.local.json 105: * 106: * Here `-/../.claude/settings.local.json` starts with `-` so the naive filter 107: * drops it, validation sees zero paths, returns passthrough, and the file is 108: * deleted without a prompt. With `--` handling, the path IS extracted and 109: * validated (blocked by isClaudeConfigFilePath / pathInAllowedWorkingPath). 110: */ 111: function filterOutFlags(args: string[]): string[] { 112: const result: string[] = [] 113: let afterDoubleDash = false 114: for (const arg of args) { 115: if (afterDoubleDash) { 116: result.push(arg) 117: } else if (arg === '--') { 118: afterDoubleDash = true 119: } else if (!arg?.startsWith('-')) { 120: result.push(arg) 121: } 122: } 123: return result 124: } 125: // Helper: Parse grep/rg style commands (pattern then paths) 126: function parsePatternCommand( 127: args: string[], 128: flagsWithArgs: Set<string>, 129: defaults: string[] = [], 130: ): string[] { 131: const paths: string[] = [] 132: let patternFound = false 133: // SECURITY: Track `--` end-of-options delimiter. After `--`, all args are 134: // positional regardless of leading `-`. See filterOutFlags() doc comment. 135: let afterDoubleDash = false 136: for (let i = 0; i < args.length; i++) { 137: const arg = args[i] 138: if (arg === undefined || arg === null) continue 139: if (!afterDoubleDash && arg === '--') { 140: afterDoubleDash = true 141: continue 142: } 143: if (!afterDoubleDash && arg.startsWith('-')) { 144: const flag = arg.split('=')[0] 145: // Pattern flags mark that we've found the pattern 146: if (flag && ['-e', '--regexp', '-f', '--file'].includes(flag)) { 147: patternFound = true 148: } 149: // Skip next arg if flag needs it 150: if (flag && flagsWithArgs.has(flag) && !arg.includes('=')) { 151: i++ 152: } 153: continue 154: } 155: // First non-flag is pattern, rest are paths 156: if (!patternFound) { 157: patternFound = true 158: continue 159: } 160: paths.push(arg) 161: } 162: return paths.length > 0 ? paths : defaults 163: } 164: /** 165: * Extracts paths from command arguments for different path commands. 166: * Each command has specific logic for how it handles paths and flags. 167: */ 168: export const PATH_EXTRACTORS: Record< 169: PathCommand, 170: (args: string[]) => string[] 171: > = { 172: // cd: special case - all args form one path 173: cd: args => (args.length === 0 ? [homedir()] : [args.join(' ')]), 174: // ls: filter flags, default to current dir 175: ls: args => { 176: const paths = filterOutFlags(args) 177: return paths.length > 0 ? paths : ['.'] 178: }, 179: // find: collect paths until hitting a real flag, also check path-taking flags 180: // SECURITY: `find -- -path` makes `-path` a starting point (not a predicate). 181: // GNU find supports `--` to allow search roots starting with `-`. After `--`, 182: // we conservatively collect all remaining args as paths to validate. This 183: // over-includes predicates like `-name foo`, but find is a read-only op and 184: // predicates resolve to paths within cwd (allowed), so no false blocks for 185: // legitimate use. The over-inclusion ensures attack paths like 186: // `find -- -/../../etc` are caught. 187: find: args => { 188: const paths: string[] = [] 189: const pathFlags = new Set([ 190: '-newer', 191: '-anewer', 192: '-cnewer', 193: '-mnewer', 194: '-samefile', 195: '-path', 196: '-wholename', 197: '-ilname', 198: '-lname', 199: '-ipath', 200: '-iwholename', 201: ]) 202: const newerPattern = /^-newer[acmBt][acmtB]$/ 203: let foundNonGlobalFlag = false 204: let afterDoubleDash = false 205: for (let i = 0; i < args.length; i++) { 206: const arg = args[i] 207: if (!arg) continue 208: if (afterDoubleDash) { 209: paths.push(arg) 210: continue 211: } 212: if (arg === '--') { 213: afterDoubleDash = true 214: continue 215: } 216: // Handle flags 217: if (arg.startsWith('-')) { 218: // Global options don't stop collection 219: if (['-H', '-L', '-P'].includes(arg)) continue 220: // Mark that we've seen a non-global flag 221: foundNonGlobalFlag = true 222: // Check if this flag takes a path argument 223: if (pathFlags.has(arg) || newerPattern.test(arg)) { 224: const nextArg = args[i + 1] 225: if (nextArg) { 226: paths.push(nextArg) 227: i++ // Skip the path we just processed 228: } 229: } 230: continue 231: } 232: // Only collect non-flag arguments before first non-global flag 233: if (!foundNonGlobalFlag) { 234: paths.push(arg) 235: } 236: } 237: return paths.length > 0 ? paths : ['.'] 238: }, 239: // All simple commands: just filter out flags 240: mkdir: filterOutFlags, 241: touch: filterOutFlags, 242: rm: filterOutFlags, 243: rmdir: filterOutFlags, 244: mv: filterOutFlags, 245: cp: filterOutFlags, 246: cat: filterOutFlags, 247: head: filterOutFlags, 248: tail: filterOutFlags, 249: sort: filterOutFlags, 250: uniq: filterOutFlags, 251: wc: filterOutFlags, 252: cut: filterOutFlags, 253: paste: filterOutFlags, 254: column: filterOutFlags, 255: file: filterOutFlags, 256: stat: filterOutFlags, 257: diff: filterOutFlags, 258: awk: filterOutFlags, 259: strings: filterOutFlags, 260: hexdump: filterOutFlags, 261: od: filterOutFlags, 262: base64: filterOutFlags, 263: nl: filterOutFlags, 264: sha256sum: filterOutFlags, 265: sha1sum: filterOutFlags, 266: md5sum: filterOutFlags, 267: // tr: special case - skip character sets 268: tr: args => { 269: const hasDelete = args.some( 270: a => 271: a === '-d' || 272: a === '--delete' || 273: (a.startsWith('-') && a.includes('d')), 274: ) 275: const nonFlags = filterOutFlags(args) 276: return nonFlags.slice(hasDelete ? 1 : 2) // Skip SET1 or SET1+SET2 277: }, 278: // grep: pattern then paths, defaults to stdin 279: grep: args => { 280: const flags = new Set([ 281: '-e', 282: '--regexp', 283: '-f', 284: '--file', 285: '--exclude', 286: '--include', 287: '--exclude-dir', 288: '--include-dir', 289: '-m', 290: '--max-count', 291: '-A', 292: '--after-context', 293: '-B', 294: '--before-context', 295: '-C', 296: '--context', 297: ]) 298: const paths = parsePatternCommand(args, flags) 299: // Special: if -r/-R flag present and no paths, use current dir 300: if ( 301: paths.length === 0 && 302: args.some(a => ['-r', '-R', '--recursive'].includes(a)) 303: ) { 304: return ['.'] 305: } 306: return paths 307: }, 308: // rg: pattern then paths, defaults to current dir 309: rg: args => { 310: const flags = new Set([ 311: '-e', 312: '--regexp', 313: '-f', 314: '--file', 315: '-t', 316: '--type', 317: '-T', 318: '--type-not', 319: '-g', 320: '--glob', 321: '-m', 322: '--max-count', 323: '--max-depth', 324: '-r', 325: '--replace', 326: '-A', 327: '--after-context', 328: '-B', 329: '--before-context', 330: '-C', 331: '--context', 332: ]) 333: return parsePatternCommand(args, flags, ['.']) 334: }, 335: // sed: processes files in-place or reads from stdin 336: sed: args => { 337: const paths: string[] = [] 338: let skipNext = false 339: let scriptFound = false 340: // SECURITY: Track `--` end-of-options delimiter. After `--`, all args are 341: // positional regardless of leading `-`. See filterOutFlags() doc comment. 342: let afterDoubleDash = false 343: for (let i = 0; i < args.length; i++) { 344: if (skipNext) { 345: skipNext = false 346: continue 347: } 348: const arg = args[i] 349: if (!arg) continue 350: if (!afterDoubleDash && arg === '--') { 351: afterDoubleDash = true 352: continue 353: } 354: // Handle flags (only before `--`) 355: if (!afterDoubleDash && arg.startsWith('-')) { 356: // -f flag: next arg is a script file that needs validation 357: if (['-f', '--file'].includes(arg)) { 358: const scriptFile = args[i + 1] 359: if (scriptFile) { 360: paths.push(scriptFile) // Add script file to paths for validation 361: skipNext = true 362: } 363: scriptFound = true 364: } 365: // -e flag: next arg is expression, not a file 366: else if (['-e', '--expression'].includes(arg)) { 367: skipNext = true 368: scriptFound = true 369: } 370: // Combined flags like -ie or -nf 371: else if (arg.includes('e') || arg.includes('f')) { 372: scriptFound = true 373: } 374: continue 375: } 376: // First non-flag is the script (if not already found via -e/-f) 377: if (!scriptFound) { 378: scriptFound = true 379: continue 380: } 381: // Rest are file paths 382: paths.push(arg) 383: } 384: return paths 385: }, 386: // jq: filter then file paths (similar to grep) 387: // The jq command structure is: jq [flags] filter [files...] 388: // If no files are provided, jq reads from stdin 389: jq: args => { 390: const paths: string[] = [] 391: const flagsWithArgs = new Set([ 392: '-e', 393: '--expression', 394: '-f', 395: '--from-file', 396: '--arg', 397: '--argjson', 398: '--slurpfile', 399: '--rawfile', 400: '--args', 401: '--jsonargs', 402: '-L', 403: '--library-path', 404: '--indent', 405: '--tab', 406: ]) 407: let filterFound = false 408: // SECURITY: Track `--` end-of-options delimiter. After `--`, all args are 409: // positional regardless of leading `-`. See filterOutFlags() doc comment. 410: let afterDoubleDash = false 411: for (let i = 0; i < args.length; i++) { 412: const arg = args[i] 413: if (arg === undefined || arg === null) continue 414: if (!afterDoubleDash && arg === '--') { 415: afterDoubleDash = true 416: continue 417: } 418: if (!afterDoubleDash && arg.startsWith('-')) { 419: const flag = arg.split('=')[0] 420: // Pattern flags mark that we've found the filter 421: if (flag && ['-e', '--expression'].includes(flag)) { 422: filterFound = true 423: } 424: // Skip next arg if flag needs it 425: if (flag && flagsWithArgs.has(flag) && !arg.includes('=')) { 426: i++ 427: } 428: continue 429: } 430: // First non-flag is filter, rest are file paths 431: if (!filterFound) { 432: filterFound = true 433: continue 434: } 435: paths.push(arg) 436: } 437: // If no file paths, jq reads from stdin (no paths to validate) 438: return paths 439: }, 440: // git: handle subcommands that access arbitrary files outside the repository 441: git: args => { 442: // git diff --no-index is special - it explicitly compares files outside git's control 443: // This flag allows git diff to compare any two files on the filesystem, not just 444: // files within the repository, which is why it needs path validation 445: if (args.length >= 1 && args[0] === 'diff') { 446: if (args.includes('--no-index')) { 447: // SECURITY: git diff --no-index accepts `--` before file paths. 448: // Use filterOutFlags which handles `--` correctly instead of naive 449: // startsWith('-') filtering, to catch paths like `-/../etc/passwd`. 450: const filePaths = filterOutFlags(args.slice(1)) 451: return filePaths.slice(0, 2) // git diff --no-index expects exactly 2 paths 452: } 453: } 454: // Other git commands (add, rm, mv, show, etc.) operate within the repository context 455: // and are already constrained by git's own security model, so they don't need 456: // additional path validation 457: return [] 458: }, 459: } 460: const SUPPORTED_PATH_COMMANDS = Object.keys(PATH_EXTRACTORS) as PathCommand[] 461: const ACTION_VERBS: Record<PathCommand, string> = { 462: cd: 'change directories to', 463: ls: 'list files in', 464: find: 'search files in', 465: mkdir: 'create directories in', 466: touch: 'create or modify files in', 467: rm: 'remove files from', 468: rmdir: 'remove directories from', 469: mv: 'move files to/from', 470: cp: 'copy files to/from', 471: cat: 'concatenate files from', 472: head: 'read the beginning of files from', 473: tail: 'read the end of files from', 474: sort: 'sort contents of files from', 475: uniq: 'filter duplicate lines from files in', 476: wc: 'count lines/words/bytes in files from', 477: cut: 'extract columns from files in', 478: paste: 'merge files from', 479: column: 'format files from', 480: tr: 'transform text from files in', 481: file: 'examine file types in', 482: stat: 'read file stats from', 483: diff: 'compare files from', 484: awk: 'process text from files in', 485: strings: 'extract strings from files in', 486: hexdump: 'display hex dump of files from', 487: od: 'display octal dump of files from', 488: base64: 'encode/decode files from', 489: nl: 'number lines in files from', 490: grep: 'search for patterns in files from', 491: rg: 'search for patterns in files from', 492: sed: 'edit files in', 493: git: 'access files with git from', 494: jq: 'process JSON from files in', 495: sha256sum: 'compute SHA-256 checksums for files in', 496: sha1sum: 'compute SHA-1 checksums for files in', 497: md5sum: 'compute MD5 checksums for files in', 498: } 499: export const COMMAND_OPERATION_TYPE: Record<PathCommand, FileOperationType> = { 500: cd: 'read', 501: ls: 'read', 502: find: 'read', 503: mkdir: 'create', 504: touch: 'create', 505: rm: 'write', 506: rmdir: 'write', 507: mv: 'write', 508: cp: 'write', 509: cat: 'read', 510: head: 'read', 511: tail: 'read', 512: sort: 'read', 513: uniq: 'read', 514: wc: 'read', 515: cut: 'read', 516: paste: 'read', 517: column: 'read', 518: tr: 'read', 519: file: 'read', 520: stat: 'read', 521: diff: 'read', 522: awk: 'read', 523: strings: 'read', 524: hexdump: 'read', 525: od: 'read', 526: base64: 'read', 527: nl: 'read', 528: grep: 'read', 529: rg: 'read', 530: sed: 'write', 531: git: 'read', 532: jq: 'read', 533: sha256sum: 'read', 534: sha1sum: 'read', 535: md5sum: 'read', 536: } 537: /** 538: * Command-specific validators that run before path validation. 539: * Returns true if the command is valid, false if it should be rejected. 540: * Used to block commands with flags that could bypass path validation. 541: */ 542: const COMMAND_VALIDATOR: Partial< 543: Record<PathCommand, (args: string[]) => boolean> 544: > = { 545: mv: (args: string[]) => !args.some(arg => arg?.startsWith('-')), 546: cp: (args: string[]) => !args.some(arg => arg?.startsWith('-')), 547: } 548: function validateCommandPaths( 549: command: PathCommand, 550: args: string[], 551: cwd: string, 552: toolPermissionContext: ToolPermissionContext, 553: compoundCommandHasCd?: boolean, 554: operationTypeOverride?: FileOperationType, 555: ): PermissionResult { 556: const extractor = PATH_EXTRACTORS[command] 557: const paths = extractor(args) 558: const operationType = operationTypeOverride ?? COMMAND_OPERATION_TYPE[command] 559: // SECURITY: Check command-specific validators (e.g., to block flags that could bypass path validation) 560: // Some commands like mv/cp have flags (--target-directory=PATH) that can bypass path extraction, 561: // so we block ALL flags for these commands to ensure security. 562: const validator = COMMAND_VALIDATOR[command] 563: if (validator && !validator(args)) { 564: return { 565: behavior: 'ask', 566: message: `${command} with flags requires manual approval to ensure path safety. For security, Claude Code cannot automatically validate ${command} commands that use flags, as some flags like --target-directory=PATH can bypass path validation.`, 567: decisionReason: { 568: type: 'other', 569: reason: `${command} command with flags requires manual approval`, 570: }, 571: } 572: } 573: // SECURITY: Block write operations in compound commands containing 'cd' 574: // This prevents bypassing path safety checks via directory changes before operations. 575: // Example attack: cd .claude/ && mv test.txt settings.json 576: // This would bypass the check for .claude/settings.json because paths are resolved 577: // relative to the original CWD, not accounting for the cd's effect. 578: // 579: // ALTERNATIVE APPROACH: Instead of blocking all writes with cd, we could track the 580: // effective CWD through the command chain (e.g., after "cd .claude/", subsequent 581: // commands would be validated with CWD=".claude/"). This would be more permissive 582: // but requires careful handling of: 583: // - Relative paths (cd ../foo) 584: // - Special cd targets (cd ~, cd -, cd with no args) 585: // - Multiple cd commands in sequence 586: // - Error cases where cd target cannot be determined 587: // For now, we take the conservative approach of requiring manual approval. 588: if (compoundCommandHasCd && operationType !== 'read') { 589: return { 590: behavior: 'ask', 591: message: `Commands that change directories and perform write operations require explicit approval to ensure paths are evaluated correctly. For security, Claude Code cannot automatically determine the final working directory when 'cd' is used in compound commands.`, 592: decisionReason: { 593: type: 'other', 594: reason: 595: 'Compound command contains cd with write operation - manual approval required to prevent path resolution bypass', 596: }, 597: } 598: } 599: for (const path of paths) { 600: const { allowed, resolvedPath, decisionReason } = validatePath( 601: path, 602: cwd, 603: toolPermissionContext, 604: operationType, 605: ) 606: if (!allowed) { 607: const workingDirs = Array.from( 608: allWorkingDirectories(toolPermissionContext), 609: ) 610: const dirListStr = formatDirectoryList(workingDirs) 611: // Use security check's custom reason if available (type: 'other' or 'safetyCheck') 612: // Otherwise use the standard "was blocked" message 613: const message = 614: decisionReason?.type === 'other' || 615: decisionReason?.type === 'safetyCheck' 616: ? decisionReason.reason 617: : `${command} in '${resolvedPath}' was blocked. For security, Claude Code may only ${ACTION_VERBS[command]} the allowed working directories for this session: ${dirListStr}.` 618: if (decisionReason?.type === 'rule') { 619: return { 620: behavior: 'deny', 621: message, 622: decisionReason, 623: } 624: } 625: return { 626: behavior: 'ask', 627: message, 628: blockedPath: resolvedPath, 629: decisionReason, 630: } 631: } 632: } 633: return { 634: behavior: 'passthrough', 635: message: `Path validation passed for ${command} command`, 636: } 637: } 638: export function createPathChecker( 639: command: PathCommand, 640: operationTypeOverride?: FileOperationType, 641: ) { 642: return ( 643: args: string[], 644: cwd: string, 645: context: ToolPermissionContext, 646: compoundCommandHasCd?: boolean, 647: ): PermissionResult => { 648: const result = validateCommandPaths( 649: command, 650: args, 651: cwd, 652: context, 653: compoundCommandHasCd, 654: operationTypeOverride, 655: ) 656: if (result.behavior === 'deny') { 657: return result 658: } 659: if (command === 'rm' || command === 'rmdir') { 660: const dangerousPathResult = checkDangerousRemovalPaths(command, args, cwd) 661: if (dangerousPathResult.behavior !== 'passthrough') { 662: return dangerousPathResult 663: } 664: } 665: if (result.behavior === 'passthrough') { 666: return result 667: } 668: if (result.behavior === 'ask') { 669: const operationType = 670: operationTypeOverride ?? COMMAND_OPERATION_TYPE[command] 671: const suggestions: PermissionUpdate[] = [] 672: if (result.blockedPath) { 673: if (operationType === 'read') { 674: const dirPath = getDirectoryForPath(result.blockedPath) 675: const suggestion = createReadRuleSuggestion(dirPath, 'session') 676: if (suggestion) { 677: suggestions.push(suggestion) 678: } 679: } else { 680: suggestions.push({ 681: type: 'addDirectories', 682: directories: [getDirectoryForPath(result.blockedPath)], 683: destination: 'session', 684: }) 685: } 686: } 687: if (operationType === 'write' || operationType === 'create') { 688: suggestions.push({ 689: type: 'setMode', 690: mode: 'acceptEdits', 691: destination: 'session', 692: }) 693: } 694: result.suggestions = suggestions 695: } 696: return result 697: } 698: } 699: function parseCommandArguments(cmd: string): string[] { 700: const parseResult = tryParseShellCommand(cmd, env => `$${env}`) 701: if (!parseResult.success) { 702: return [] 703: } 704: const parsed = parseResult.tokens 705: const extractedArgs: string[] = [] 706: for (const arg of parsed) { 707: if (typeof arg === 'string') { 708: extractedArgs.push(arg) 709: } else if ( 710: typeof arg === 'object' && 711: arg !== null && 712: 'op' in arg && 713: arg.op === 'glob' && 714: 'pattern' in arg 715: ) { 716: extractedArgs.push(String(arg.pattern)) 717: } 718: } 719: return extractedArgs 720: } 721: function validateSinglePathCommand( 722: cmd: string, 723: cwd: string, 724: toolPermissionContext: ToolPermissionContext, 725: compoundCommandHasCd?: boolean, 726: ): PermissionResult { 727: const strippedCmd = stripSafeWrappers(cmd) 728: const extractedArgs = parseCommandArguments(strippedCmd) 729: if (extractedArgs.length === 0) { 730: return { 731: behavior: 'passthrough', 732: message: 'Empty command - no paths to validate', 733: } 734: } 735: const [baseCmd, ...args] = extractedArgs 736: if (!baseCmd || !SUPPORTED_PATH_COMMANDS.includes(baseCmd as PathCommand)) { 737: return { 738: behavior: 'passthrough', 739: message: `Command '${baseCmd}' is not a path-restricted command`, 740: } 741: } 742: const operationTypeOverride = 743: baseCmd === 'sed' && sedCommandIsAllowedByAllowlist(strippedCmd) 744: ? ('read' as FileOperationType) 745: : undefined 746: const pathChecker = createPathChecker( 747: baseCmd as PathCommand, 748: operationTypeOverride, 749: ) 750: return pathChecker(args, cwd, toolPermissionContext, compoundCommandHasCd) 751: } 752: function validateSinglePathCommandArgv( 753: cmd: SimpleCommand, 754: cwd: string, 755: toolPermissionContext: ToolPermissionContext, 756: compoundCommandHasCd?: boolean, 757: ): PermissionResult { 758: const argv = stripWrappersFromArgv(cmd.argv) 759: if (argv.length === 0) { 760: return { 761: behavior: 'passthrough', 762: message: 'Empty command - no paths to validate', 763: } 764: } 765: const [baseCmd, ...args] = argv 766: if (!baseCmd || !SUPPORTED_PATH_COMMANDS.includes(baseCmd as PathCommand)) { 767: return { 768: behavior: 'passthrough', 769: message: `Command '${baseCmd}' is not a path-restricted command`, 770: } 771: } 772: const operationTypeOverride = 773: baseCmd === 'sed' && 774: sedCommandIsAllowedByAllowlist(stripSafeWrappers(cmd.text)) 775: ? ('read' as FileOperationType) 776: : undefined 777: const pathChecker = createPathChecker( 778: baseCmd as PathCommand, 779: operationTypeOverride, 780: ) 781: return pathChecker(args, cwd, toolPermissionContext, compoundCommandHasCd) 782: } 783: function validateOutputRedirections( 784: redirections: Array<{ target: string; operator: '>' | '>>' }>, 785: cwd: string, 786: toolPermissionContext: ToolPermissionContext, 787: compoundCommandHasCd?: boolean, 788: ): PermissionResult { 789: if (compoundCommandHasCd && redirections.length > 0) { 790: return { 791: behavior: 'ask', 792: message: `Commands that change directories and write via output redirection require explicit approval to ensure paths are evaluated correctly. For security, Claude Code cannot automatically determine the final working directory when 'cd' is used in compound commands.`, 793: decisionReason: { 794: type: 'other', 795: reason: 796: 'Compound command contains cd with output redirection - manual approval required to prevent path resolution bypass', 797: }, 798: } 799: } 800: for (const { target } of redirections) { 801: if (target === '/dev/null') { 802: continue 803: } 804: const { allowed, resolvedPath, decisionReason } = validatePath( 805: target, 806: cwd, 807: toolPermissionContext, 808: 'create', 809: ) 810: if (!allowed) { 811: const workingDirs = Array.from( 812: allWorkingDirectories(toolPermissionContext), 813: ) 814: const dirListStr = formatDirectoryList(workingDirs) 815: const message = 816: decisionReason?.type === 'other' || 817: decisionReason?.type === 'safetyCheck' 818: ? decisionReason.reason 819: : decisionReason?.type === 'rule' 820: ? `Output redirection to '${resolvedPath}' was blocked by a deny rule.` 821: : `Output redirection to '${resolvedPath}' was blocked. For security, Claude Code may only write to files in the allowed working directories for this session: ${dirListStr}.` 822: if (decisionReason?.type === 'rule') { 823: return { 824: behavior: 'deny', 825: message, 826: decisionReason, 827: } 828: } 829: return { 830: behavior: 'ask', 831: message, 832: blockedPath: resolvedPath, 833: decisionReason, 834: suggestions: [ 835: { 836: type: 'addDirectories', 837: directories: [getDirectoryForPath(resolvedPath)], 838: destination: 'session', 839: }, 840: ], 841: } 842: } 843: } 844: return { 845: behavior: 'passthrough', 846: message: 'No unsafe redirections found', 847: } 848: } 849: export function checkPathConstraints( 850: input: z.infer<typeof BashTool.inputSchema>, 851: cwd: string, 852: toolPermissionContext: ToolPermissionContext, 853: compoundCommandHasCd?: boolean, 854: astRedirects?: Redirect[], 855: astCommands?: SimpleCommand[], 856: ): PermissionResult { 857: if (!astCommands && />>\s*>\s*\(|>\s*>\s*\(|<\s*\(/.test(input.command)) { 858: return { 859: behavior: 'ask', 860: message: 861: 'Process substitution (>(...) or <(...)) can execute arbitrary commands and requires manual approval', 862: decisionReason: { 863: type: 'other', 864: reason: 'Process substitution requires manual approval', 865: }, 866: } 867: } 868: const { redirections, hasDangerousRedirection } = astRedirects 869: ? astRedirectsToOutputRedirections(astRedirects) 870: : extractOutputRedirections(input.command) 871: if (hasDangerousRedirection) { 872: return { 873: behavior: 'ask', 874: message: 'Shell expansion syntax in paths requires manual approval', 875: decisionReason: { 876: type: 'other', 877: reason: 'Shell expansion syntax in paths requires manual approval', 878: }, 879: } 880: } 881: const redirectionResult = validateOutputRedirections( 882: redirections, 883: cwd, 884: toolPermissionContext, 885: compoundCommandHasCd, 886: ) 887: if (redirectionResult.behavior !== 'passthrough') { 888: return redirectionResult 889: } 890: if (astCommands) { 891: for (const cmd of astCommands) { 892: const result = validateSinglePathCommandArgv( 893: cmd, 894: cwd, 895: toolPermissionContext, 896: compoundCommandHasCd, 897: ) 898: if (result.behavior === 'ask' || result.behavior === 'deny') { 899: return result 900: } 901: } 902: } else { 903: const commands = splitCommand_DEPRECATED(input.command) 904: for (const cmd of commands) { 905: const result = validateSinglePathCommand( 906: cmd, 907: cwd, 908: toolPermissionContext, 909: compoundCommandHasCd, 910: ) 911: if (result.behavior === 'ask' || result.behavior === 'deny') { 912: return result 913: } 914: } 915: } 916: return { 917: behavior: 'passthrough', 918: message: 'All path commands validated successfully', 919: } 920: } 921: function astRedirectsToOutputRedirections(redirects: Redirect[]): { 922: redirections: Array<{ target: string; operator: '>' | '>>' }> 923: hasDangerousRedirection: boolean 924: } { 925: const redirections: Array<{ target: string; operator: '>' | '>>' }> = [] 926: for (const r of redirects) { 927: switch (r.op) { 928: case '>': 929: case '>|': 930: case '&>': 931: redirections.push({ target: r.target, operator: '>' }) 932: break 933: case '>>': 934: case '&>>': 935: redirections.push({ target: r.target, operator: '>>' }) 936: break 937: case '>&': 938: if (!/^\d+$/.test(r.target)) { 939: redirections.push({ target: r.target, operator: '>' }) 940: } 941: break 942: case '<': 943: case '<<': 944: case '<&': 945: case '<<<': 946: break 947: } 948: } 949: return { redirections, hasDangerousRedirection: false } 950: } 951: const TIMEOUT_FLAG_VALUE_RE = /^[A-Za-z0-9_.+-]+$/ 952: function skipTimeoutFlags(a: readonly string[]): number { 953: let i = 1 954: while (i < a.length) { 955: const arg = a[i]! 956: const next = a[i + 1] 957: if ( 958: arg === '--foreground' || 959: arg === '--preserve-status' || 960: arg === '--verbose' 961: ) 962: i++ 963: else if (/^--(?:kill-after|signal)=[A-Za-z0-9_.+-]+$/.test(arg)) i++ 964: else if ( 965: (arg === '--kill-after' || arg === '--signal') && 966: next && 967: TIMEOUT_FLAG_VALUE_RE.test(next) 968: ) 969: i += 2 970: else if (arg === '--') { 971: i++ 972: break 973: } 974: else if (arg.startsWith('--')) return -1 975: else if (arg === '-v') i++ 976: else if ( 977: (arg === '-k' || arg === '-s') && 978: next && 979: TIMEOUT_FLAG_VALUE_RE.test(next) 980: ) 981: i += 2 982: else if (/^-[ks][A-Za-z0-9_.+-]+$/.test(arg)) i++ 983: else if (arg.startsWith('-')) return -1 984: else break 985: } 986: return i 987: } 988: function skipStdbufFlags(a: readonly string[]): number { 989: let i = 1 990: while (i < a.length) { 991: const arg = a[i]! 992: if (/^-[ioe]$/.test(arg) && a[i + 1]) i += 2 993: else if (/^-[ioe]./.test(arg)) i++ 994: else if (/^--(input|output|error)=/.test(arg)) i++ 995: else if (arg.startsWith('-')) 996: return -1 997: else break 998: } 999: return i > 1 && i < a.length ? i : -1 1000: } 1001: function skipEnvFlags(a: readonly string[]): number { 1002: let i = 1 1003: while (i < a.length) { 1004: const arg = a[i]! 1005: if (arg.includes('=') && !arg.startsWith('-')) i++ 1006: else if (arg === '-i' || arg === '-0' || arg === '-v') i++ 1007: else if (arg === '-u' && a[i + 1]) i += 2 1008: else if (arg.startsWith('-')) 1009: return -1 1010: else break 1011: } 1012: return i < a.length ? i : -1 1013: } 1014: export function stripWrappersFromArgv(argv: string[]): string[] { 1015: let a = argv 1016: for (;;) { 1017: if (a[0] === 'time' || a[0] === 'nohup') { 1018: a = a.slice(a[1] === '--' ? 2 : 1) 1019: } else if (a[0] === 'timeout') { 1020: const i = skipTimeoutFlags(a) 1021: if (i < 0 || !a[i] || !/^\d+(?:\.\d+)?[smhd]?$/.test(a[i]!)) return a 1022: a = a.slice(i + 1) 1023: } else if (a[0] === 'nice') { 1024: if (a[1] === '-n' && a[2] && /^-?\d+$/.test(a[2])) 1025: a = a.slice(a[3] === '--' ? 4 : 3) 1026: else if (a[1] && /^-\d+$/.test(a[1])) a = a.slice(a[2] === '--' ? 3 : 2) 1027: else a = a.slice(a[1] === '--' ? 2 : 1) 1028: } else if (a[0] === 'stdbuf') { 1029: const i = skipStdbufFlags(a) 1030: if (i < 0) return a 1031: a = a.slice(i) 1032: } else if (a[0] === 'env') { 1033: const i = skipEnvFlags(a) 1034: if (i < 0) return a 1035: a = a.slice(i) 1036: } else { 1037: return a 1038: } 1039: } 1040: }

File: src/tools/BashTool/prompt.ts

typescript 1: import { feature } from 'bun:bundle' 2: import { prependBullets } from '../../constants/prompts.js' 3: import { getAttributionTexts } from '../../utils/attribution.js' 4: import { hasEmbeddedSearchTools } from '../../utils/embeddedTools.js' 5: import { isEnvTruthy } from '../../utils/envUtils.js' 6: import { shouldIncludeGitInstructions } from '../../utils/gitSettings.js' 7: import { getClaudeTempDir } from '../../utils/permissions/filesystem.js' 8: import { SandboxManager } from '../../utils/sandbox/sandbox-adapter.js' 9: import { jsonStringify } from '../../utils/slowOperations.js' 10: import { 11: getDefaultBashTimeoutMs, 12: getMaxBashTimeoutMs, 13: } from '../../utils/timeouts.js' 14: import { 15: getUndercoverInstructions, 16: isUndercover, 17: } from '../../utils/undercover.js' 18: import { AGENT_TOOL_NAME } from '../AgentTool/constants.js' 19: import { FILE_EDIT_TOOL_NAME } from '../FileEditTool/constants.js' 20: import { FILE_READ_TOOL_NAME } from '../FileReadTool/prompt.js' 21: import { FILE_WRITE_TOOL_NAME } from '../FileWriteTool/prompt.js' 22: import { GLOB_TOOL_NAME } from '../GlobTool/prompt.js' 23: import { GREP_TOOL_NAME } from '../GrepTool/prompt.js' 24: import { TodoWriteTool } from '../TodoWriteTool/TodoWriteTool.js' 25: import { BASH_TOOL_NAME } from './toolName.js' 26: export function getDefaultTimeoutMs(): number { 27: return getDefaultBashTimeoutMs() 28: } 29: export function getMaxTimeoutMs(): number { 30: return getMaxBashTimeoutMs() 31: } 32: function getBackgroundUsageNote(): string | null { 33: if (isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_BACKGROUND_TASKS)) { 34: return null 35: } 36: return "You can use the `run_in_background` parameter to run the command in the background. Only use this if you don't need the result immediately and are OK being notified when the command completes later. You do not need to check the output right away - you'll be notified when it finishes. You do not need to use '&' at the end of the command when using this parameter." 37: } 38: function getCommitAndPRInstructions(): string { 39: const undercoverSection = 40: process.env.USER_TYPE === 'ant' && isUndercover() 41: ? getUndercoverInstructions() + '\n' 42: : '' 43: if (!shouldIncludeGitInstructions()) return undercoverSection 44: // For ant users, use the short version pointing to skills 45: if (process.env.USER_TYPE === 'ant') { 46: const skillsSection = !isEnvTruthy(process.env.CLAUDE_CODE_SIMPLE) 47: ? `For git commits and pull requests, use the \`/commit\` and \`/commit-push-pr\` skills: 48: - \`/commit\` - Create a git commit with staged changes 49: - \`/commit-push-pr\` - Commit, push, and create a pull request 50: These skills handle git safety protocols, proper commit message formatting, and PR creation. 51: Before creating a pull request, run \`/simplify\` to review your changes, then test end-to-end (e.g. via \`/tmux\` for interactive features). 52: ` 53: : '' 54: return `${undercoverSection}# Git operations 55: ${skillsSection}IMPORTANT: NEVER skip hooks (--no-verify, --no-gpg-sign, etc) unless the user explicitly requests it. 56: Use the gh command via the Bash tool for other GitHub-related tasks including working with issues, checks, and releases. If given a Github URL use the gh command to get the information needed. 57: # Other common operations 58: - View comments on a Github PR: gh api repos/foo/bar/pulls/123/comments` 59: } 60: // For external users, include full inline instructions 61: const { commit: commitAttribution, pr: prAttribution } = getAttributionTexts() 62: return `# Committing changes with git 63: Only create commits when requested by the user. If unclear, ask first. When the user asks you to create a new git commit, follow these steps carefully: 64: You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. The numbered steps below indicate which commands should be batched in parallel. 65: Git Safety Protocol: 66: - NEVER update the git config 67: - NEVER run destructive git commands (push --force, reset --hard, checkout ., restore ., clean -f, branch -D) unless the user explicitly requests these actions. Taking unauthorized destructive actions is unhelpful and can result in lost work, so it's best to ONLY run these commands when given direct instructions 68: - NEVER skip hooks (--no-verify, --no-gpg-sign, etc) unless the user explicitly requests it 69: - NEVER run force push to main/master, warn the user if they request it 70: - CRITICAL: Always create NEW commits rather than amending, unless the user explicitly requests a git amend. When a pre-commit hook fails, the commit did NOT happen — so --amend would modify the PREVIOUS commit, which may result in destroying work or losing previous changes. Instead, after hook failure, fix the issue, re-stage, and create a NEW commit 71: - When staging files, prefer adding specific files by name rather than using "git add -A" or "git add .", which can accidentally include sensitive files (.env, credentials) or large binaries 72: - NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTANT to only commit when explicitly asked, otherwise the user will feel that you are being too proactive 73: 1. Run the following bash commands in parallel, each using the ${BASH_TOOL_NAME} tool: 74: - Run a git status command to see all untracked files. IMPORTANT: Never use the -uall flag as it can cause memory issues on large repos. 75: - Run a git diff command to see both staged and unstaged changes that will be committed. 76: - Run a git log command to see recent commit messages, so that you can follow this repository's commit message style. 77: 2. Analyze all staged changes (both previously staged and newly added) and draft a commit message: 78: - Summarize the nature of the changes (eg. new feature, enhancement to an existing feature, bug fix, refactoring, test, docs, etc.). Ensure the message accurately reflects the changes and their purpose (i.e. "add" means a wholly new feature, "update" means an enhancement to an existing feature, "fix" means a bug fix, etc.). 79: - Do not commit files that likely contain secrets (.env, credentials.json, etc). Warn the user if they specifically request to commit those files 80: - Draft a concise (1-2 sentences) commit message that focuses on the "why" rather than the "what" 81: - Ensure it accurately reflects the changes and their purpose 82: 3. Run the following commands in parallel: 83: - Add relevant untracked files to the staging area. 84: - Create the commit with a message${commitAttribution ? ` ending with:\n ${commitAttribution}` : '.'} 85: - Run git status after the commit completes to verify success. 86: Note: git status depends on the commit completing, so run it sequentially after the commit. 87: 4. If the commit fails due to pre-commit hook: fix the issue and create a NEW commit 88: Important notes: 89: - NEVER run additional commands to read or explore code, besides git bash commands 90: - NEVER use the ${TodoWriteTool.name} or ${AGENT_TOOL_NAME} tools 91: - DO NOT push to the remote repository unless the user explicitly asks you to do so 92: - IMPORTANT: Never use git commands with the -i flag (like git rebase -i or git add -i) since they require interactive input which is not supported. 93: - IMPORTANT: Do not use --no-edit with git rebase commands, as the --no-edit flag is not a valid option for git rebase. 94: - If there are no changes to commit (i.e., no untracked files and no modifications), do not create an empty commit 95: - In order to ensure good formatting, ALWAYS pass the commit message via a HEREDOC, a la this example: 96: <example> 97: git commit -m "$(cat <<'EOF' 98: Commit message here.${commitAttribution ? `\n\n ${commitAttribution}` : ''} 99: EOF 100: )" 101: </example> 102: # Creating pull requests 103: Use the gh command via the Bash tool for ALL GitHub-related tasks including working with issues, pull requests, checks, and releases. If given a Github URL use the gh command to get the information needed. 104: IMPORTANT: When the user asks you to create a pull request, follow these steps carefully: 105: 1. Run the following bash commands in parallel using the ${BASH_TOOL_NAME} tool, in order to understand the current state of the branch since it diverged from the main branch: 106: - Run a git status command to see all untracked files (never use -uall flag) 107: - Run a git diff command to see both staged and unstaged changes that will be committed 108: - Check if the current branch tracks a remote branch and is up to date with the remote, so you know if you need to push to the remote 109: - Run a git log command and \`git diff [base-branch]...HEAD\` to understand the full commit history for the current branch (from the time it diverged from the base branch) 110: 2. Analyze all changes that will be included in the pull request, making sure to look at all relevant commits (NOT just the latest commit, but ALL commits that will be included in the pull request!!!), and draft a pull request title and summary: 111: - Keep the PR title short (under 70 characters) 112: - Use the description/body for details, not the title 113: 3. Run the following commands in parallel: 114: - Create new branch if needed 115: - Push to remote with -u flag if needed 116: - Create PR using gh pr create with the format below. Use a HEREDOC to pass the body to ensure correct formatting. 117: <example> 118: gh pr create --title "the pr title" --body "$(cat <<'EOF' 119: ## Summary 120: <1-3 bullet points> 121: ## Test plan 122: [Bulleted markdown checklist of TODOs for testing the pull request...]${prAttribution ? `\n\n${prAttribution}` : ''} 123: EOF 124: )" 125: </example> 126: Important: 127: - DO NOT use the ${TodoWriteTool.name} or ${AGENT_TOOL_NAME} tools 128: - Return the PR URL when you're done, so the user can see it 129: # Other common operations 130: - View comments on a Github PR: gh api repos/foo/bar/pulls/123/comments` 131: } 132: function dedup<T>(arr: T[] | undefined): T[] | undefined { 133: if (!arr || arr.length === 0) return arr 134: return [...new Set(arr)] 135: } 136: function getSimpleSandboxSection(): string { 137: if (!SandboxManager.isSandboxingEnabled()) { 138: return '' 139: } 140: const fsReadConfig = SandboxManager.getFsReadConfig() 141: const fsWriteConfig = SandboxManager.getFsWriteConfig() 142: const networkRestrictionConfig = SandboxManager.getNetworkRestrictionConfig() 143: const allowUnixSockets = SandboxManager.getAllowUnixSockets() 144: const ignoreViolations = SandboxManager.getIgnoreViolations() 145: const allowUnsandboxedCommands = 146: SandboxManager.areUnsandboxedCommandsAllowed() 147: // Replace the per-UID temp dir literal (e.g. /private/tmp/claude-1001/) with 148: // "$TMPDIR" so the prompt is identical across users — avoids busting the 149: // cross-user global prompt cache. The sandbox already sets $TMPDIR at runtime. 150: const claudeTempDir = getClaudeTempDir() 151: const normalizeAllowOnly = (paths: string[]): string[] => 152: [...new Set(paths)].map(p => (p === claudeTempDir ? '$TMPDIR' : p)) 153: const filesystemConfig = { 154: read: { 155: denyOnly: dedup(fsReadConfig.denyOnly), 156: ...(fsReadConfig.allowWithinDeny && { 157: allowWithinDeny: dedup(fsReadConfig.allowWithinDeny), 158: }), 159: }, 160: write: { 161: allowOnly: normalizeAllowOnly(fsWriteConfig.allowOnly), 162: denyWithinAllow: dedup(fsWriteConfig.denyWithinAllow), 163: }, 164: } 165: const networkConfig = { 166: ...(networkRestrictionConfig?.allowedHosts && { 167: allowedHosts: dedup(networkRestrictionConfig.allowedHosts), 168: }), 169: ...(networkRestrictionConfig?.deniedHosts && { 170: deniedHosts: dedup(networkRestrictionConfig.deniedHosts), 171: }), 172: ...(allowUnixSockets && { allowUnixSockets: dedup(allowUnixSockets) }), 173: } 174: const restrictionsLines = [] 175: if (Object.keys(filesystemConfig).length > 0) { 176: restrictionsLines.push(`Filesystem: ${jsonStringify(filesystemConfig)}`) 177: } 178: if (Object.keys(networkConfig).length > 0) { 179: restrictionsLines.push(`Network: ${jsonStringify(networkConfig)}`) 180: } 181: if (ignoreViolations) { 182: restrictionsLines.push( 183: `Ignored violations: ${jsonStringify(ignoreViolations)}`, 184: ) 185: } 186: const sandboxOverrideItems: Array<string | string[]> = 187: allowUnsandboxedCommands 188: ? [ 189: 'You should always default to running commands within the sandbox. Do NOT attempt to set `dangerouslyDisableSandbox: true` unless:', 190: [ 191: 'The user *explicitly* asks you to bypass sandbox', 192: 'A specific command just failed and you see evidence of sandbox restrictions causing the failure. Note that commands can fail for many reasons unrelated to the sandbox (missing files, wrong arguments, network issues, etc.).', 193: ], 194: 'Evidence of sandbox-caused failures includes:', 195: [ 196: '"Operation not permitted" errors for file/network operations', 197: 'Access denied to specific paths outside allowed directories', 198: 'Network connection failures to non-whitelisted hosts', 199: 'Unix socket connection errors', 200: ], 201: 'When you see evidence of sandbox-caused failure:', 202: [ 203: "Immediately retry with `dangerouslyDisableSandbox: true` (don't ask, just do it)", 204: 'Briefly explain what sandbox restriction likely caused the failure. Be sure to mention that the user can use the `/sandbox` command to manage restrictions.', 205: 'This will prompt the user for permission', 206: ], 207: 'Treat each command you execute with `dangerouslyDisableSandbox: true` individually. Even if you have recently run a command with this setting, you should default to running future commands within the sandbox.', 208: 'Do not suggest adding sensitive paths like ~/.bashrc, ~/.zshrc, ~/.ssh/*, or credential files to the sandbox allowlist.', 209: ] 210: : [ 211: 'All commands MUST run in sandbox mode - the `dangerouslyDisableSandbox` parameter is disabled by policy.', 212: 'Commands cannot run outside the sandbox under any circumstances.', 213: 'If a command fails due to sandbox restrictions, work with the user to adjust sandbox settings instead.', 214: ] 215: const items: Array<string | string[]> = [ 216: ...sandboxOverrideItems, 217: 'For temporary files, always use the `$TMPDIR` environment variable. TMPDIR is automatically set to the correct sandbox-writable directory in sandbox mode. Do NOT use `/tmp` directly - use `$TMPDIR` instead.', 218: ] 219: return [ 220: '', 221: '## Command sandbox', 222: 'By default, your command will be run in a sandbox. This sandbox controls which directories and network hosts commands may access or modify without an explicit override.', 223: '', 224: 'The sandbox has the following restrictions:', 225: restrictionsLines.join('\n'), 226: '', 227: ...prependBullets(items), 228: ].join('\n') 229: } 230: export function getSimplePrompt(): string { 231: const embedded = hasEmbeddedSearchTools() 232: const toolPreferenceItems = [ 233: ...(embedded 234: ? [] 235: : [ 236: `File search: Use ${GLOB_TOOL_NAME} (NOT find or ls)`, 237: `Content search: Use ${GREP_TOOL_NAME} (NOT grep or rg)`, 238: ]), 239: `Read files: Use ${FILE_READ_TOOL_NAME} (NOT cat/head/tail)`, 240: `Edit files: Use ${FILE_EDIT_TOOL_NAME} (NOT sed/awk)`, 241: `Write files: Use ${FILE_WRITE_TOOL_NAME} (NOT echo >/cat <<EOF)`, 242: 'Communication: Output text directly (NOT echo/printf)', 243: ] 244: const avoidCommands = embedded 245: ? '`cat`, `head`, `tail`, `sed`, `awk`, or `echo`' 246: : '`find`, `grep`, `cat`, `head`, `tail`, `sed`, `awk`, or `echo`' 247: const multipleCommandsSubitems = [ 248: `If the commands are independent and can run in parallel, make multiple ${BASH_TOOL_NAME} tool calls in a single message. Example: if you need to run "git status" and "git diff", send a single message with two ${BASH_TOOL_NAME} tool calls in parallel.`, 249: `If the commands depend on each other and must run sequentially, use a single ${BASH_TOOL_NAME} call with '&&' to chain them together.`, 250: "Use ';' only when you need to run commands sequentially but don't care if earlier commands fail.", 251: 'DO NOT use newlines to separate commands (newlines are ok in quoted strings).', 252: ] 253: const gitSubitems = [ 254: 'Prefer to create a new commit rather than amending an existing commit.', 255: 'Before running destructive operations (e.g., git reset --hard, git push --force, git checkout --), consider whether there is a safer alternative that achieves the same goal. Only use destructive operations when they are truly the best approach.', 256: 'Never skip hooks (--no-verify) or bypass signing (--no-gpg-sign, -c commit.gpgsign=false) unless the user has explicitly asked for it. If a hook fails, investigate and fix the underlying issue.', 257: ] 258: const sleepSubitems = [ 259: 'Do not sleep between commands that can run immediately — just run them.', 260: ...(feature('MONITOR_TOOL') 261: ? [ 262: 'Use the Monitor tool to stream events from a background process (each stdout line is a notification). For one-shot "wait until done," use Bash with run_in_background instead.', 263: ] 264: : []), 265: 'If your command is long running and you would like to be notified when it finishes — use `run_in_background`. No sleep needed.', 266: 'Do not retry failing commands in a sleep loop — diagnose the root cause.', 267: 'If waiting for a background task you started with `run_in_background`, you will be notified when it completes — do not poll.', 268: ...(feature('MONITOR_TOOL') 269: ? [ 270: '`sleep N` as the first command with N ≥ 2 is blocked. If you need a delay (rate limiting, deliberate pacing), keep it under 2 seconds.', 271: ] 272: : [ 273: 'If you must poll an external process, use a check command (e.g. `gh run view`) rather than sleeping first.', 274: 'If you must sleep, keep the duration short (1-5 seconds) to avoid blocking the user.', 275: ]), 276: ] 277: const backgroundNote = getBackgroundUsageNote() 278: const instructionItems: Array<string | string[]> = [ 279: 'If your command will create new directories or files, first use this tool to run `ls` to verify the parent directory exists and is the correct location.', 280: 'Always quote file paths that contain spaces with double quotes in your command (e.g., cd "path with spaces/file.txt")', 281: 'Try to maintain your current working directory throughout the session by using absolute paths and avoiding usage of `cd`. You may use `cd` if the User explicitly requests it.', 282: `You may specify an optional timeout in milliseconds (up to ${getMaxTimeoutMs()}ms / ${getMaxTimeoutMs() / 60000} minutes). By default, your command will timeout after ${getDefaultTimeoutMs()}ms (${getDefaultTimeoutMs() / 60000} minutes).`, 283: ...(backgroundNote !== null ? [backgroundNote] : []), 284: 'When issuing multiple commands:', 285: multipleCommandsSubitems, 286: 'For git commands:', 287: gitSubitems, 288: 'Avoid unnecessary `sleep` commands:', 289: sleepSubitems, 290: ...(embedded 291: ? [ 292: "When using `find -regex` with alternation, put the longest alternative first. Example: use `'.*\\.\\(tsx\\|ts\\)'` not `'.*\\.\\(ts\\|tsx\\)'` — the second form silently skips `.tsx` files.", 293: ] 294: : []), 295: ] 296: return [ 297: 'Executes a given bash command and returns its output.', 298: '', 299: "The working directory persists between commands, but shell state does not. The shell environment is initialized from the user's profile (bash or zsh).", 300: '', 301: `IMPORTANT: Avoid using this tool to run ${avoidCommands} commands, unless explicitly instructed or after you have verified that a dedicated tool cannot accomplish your task. Instead, use the appropriate dedicated tool as this will provide a much better experience for the user:`, 302: '', 303: ...prependBullets(toolPreferenceItems), 304: `While the ${BASH_TOOL_NAME} tool can do similar things, it’s better to use the built-in tools as they provide a better user experience and make it easier to review tool calls and give permission.`, 305: '', 306: '# Instructions', 307: ...prependBullets(instructionItems), 308: getSimpleSandboxSection(), 309: ...(getCommitAndPRInstructions() ? ['', getCommitAndPRInstructions()] : []), 310: ].join('\n') 311: }

File: src/tools/BashTool/readOnlyValidation.ts

typescript 1: import type { z } from 'zod/v4' 2: import { getOriginalCwd } from '../../bootstrap/state.js' 3: import { 4: extractOutputRedirections, 5: splitCommand_DEPRECATED, 6: } from '../../utils/bash/commands.js' 7: import { tryParseShellCommand } from '../../utils/bash/shellQuote.js' 8: import { getCwd } from '../../utils/cwd.js' 9: import { isCurrentDirectoryBareGitRepo } from '../../utils/git.js' 10: import type { PermissionResult } from '../../utils/permissions/PermissionResult.js' 11: import { getPlatform } from '../../utils/platform.js' 12: import { SandboxManager } from '../../utils/sandbox/sandbox-adapter.js' 13: import { 14: containsVulnerableUncPath, 15: DOCKER_READ_ONLY_COMMANDS, 16: EXTERNAL_READONLY_COMMANDS, 17: type FlagArgType, 18: GH_READ_ONLY_COMMANDS, 19: GIT_READ_ONLY_COMMANDS, 20: PYRIGHT_READ_ONLY_COMMANDS, 21: RIPGREP_READ_ONLY_COMMANDS, 22: validateFlags, 23: } from '../../utils/shell/readOnlyCommandValidation.js' 24: import type { BashTool } from './BashTool.js' 25: import { isNormalizedGitCommand } from './bashPermissions.js' 26: import { bashCommandIsSafe_DEPRECATED } from './bashSecurity.js' 27: import { 28: COMMAND_OPERATION_TYPE, 29: PATH_EXTRACTORS, 30: type PathCommand, 31: } from './pathValidation.js' 32: import { sedCommandIsAllowedByAllowlist } from './sedValidation.js' 33: type CommandConfig = { 34: safeFlags: Record<string, FlagArgType> 35: regex?: RegExp 36: additionalCommandIsDangerousCallback?: ( 37: rawCommand: string, 38: args: string[], 39: ) => boolean 40: respectsDoubleDash?: boolean 41: } 42: const FD_SAFE_FLAGS: Record<string, FlagArgType> = { 43: '-h': 'none', 44: '--help': 'none', 45: '-V': 'none', 46: '--version': 'none', 47: '-H': 'none', 48: '--hidden': 'none', 49: '-I': 'none', 50: '--no-ignore': 'none', 51: '--no-ignore-vcs': 'none', 52: '--no-ignore-parent': 'none', 53: '-s': 'none', 54: '--case-sensitive': 'none', 55: '-i': 'none', 56: '--ignore-case': 'none', 57: '-g': 'none', 58: '--glob': 'none', 59: '--regex': 'none', 60: '-F': 'none', 61: '--fixed-strings': 'none', 62: '-a': 'none', 63: '--absolute-path': 'none', 64: '-L': 'none', 65: '--follow': 'none', 66: '-p': 'none', 67: '--full-path': 'none', 68: '-0': 'none', 69: '--print0': 'none', 70: '-d': 'number', 71: '--max-depth': 'number', 72: '--min-depth': 'number', 73: '--exact-depth': 'number', 74: '-t': 'string', 75: '--type': 'string', 76: '-e': 'string', 77: '--extension': 'string', 78: '-S': 'string', 79: '--size': 'string', 80: '--changed-within': 'string', 81: '--changed-before': 'string', 82: '-o': 'string', 83: '--owner': 'string', 84: '-E': 'string', 85: '--exclude': 'string', 86: '--ignore-file': 'string', 87: '-c': 'string', 88: '--color': 'string', 89: '-j': 'number', 90: '--threads': 'number', 91: '--max-buffer-time': 'string', 92: '--max-results': 'number', 93: '-1': 'none', 94: '-q': 'none', 95: '--quiet': 'none', 96: '--show-errors': 'none', 97: '--strip-cwd-prefix': 'none', 98: '--one-file-system': 'none', 99: '--prune': 'none', 100: '--search-path': 'string', 101: '--base-directory': 'string', 102: '--path-separator': 'string', 103: '--batch-size': 'number', 104: '--no-require-git': 'none', 105: '--hyperlink': 'string', 106: '--and': 'string', 107: '--format': 'string', 108: } 109: const COMMAND_ALLOWLIST: Record<string, CommandConfig> = { 110: xargs: { 111: safeFlags: { 112: '-I': '{}', 113: '-n': 'number', 114: '-P': 'number', 115: '-L': 'number', 116: '-s': 'number', 117: '-E': 'EOF', 118: '-0': 'none', 119: '-t': 'none', 120: '-r': 'none', 121: '-x': 'none', 122: '-d': 'char', 123: }, 124: }, 125: ...GIT_READ_ONLY_COMMANDS, 126: file: { 127: safeFlags: { 128: '--brief': 'none', 129: '-b': 'none', 130: '--mime': 'none', 131: '-i': 'none', 132: '--mime-type': 'none', 133: '--mime-encoding': 'none', 134: '--apple': 'none', 135: '--check-encoding': 'none', 136: '-c': 'none', 137: '--exclude': 'string', 138: '--exclude-quiet': 'string', 139: '--print0': 'none', 140: '-0': 'none', 141: '-f': 'string', 142: '-F': 'string', 143: '--separator': 'string', 144: '--help': 'none', 145: '--version': 'none', 146: '-v': 'none', 147: '--no-dereference': 'none', 148: '-h': 'none', 149: '--dereference': 'none', 150: '-L': 'none', 151: '--magic-file': 'string', 152: '-m': 'string', 153: '--keep-going': 'none', 154: '-k': 'none', 155: '--list': 'none', 156: '-l': 'none', 157: '--no-buffer': 'none', 158: '-n': 'none', 159: '--preserve-date': 'none', 160: '-p': 'none', 161: '--raw': 'none', 162: '-r': 'none', 163: '-s': 'none', 164: '--special-files': 'none', 165: '--uncompress': 'none', 166: '-z': 'none', 167: }, 168: }, 169: sed: { 170: safeFlags: { 171: '--expression': 'string', 172: '-e': 'string', 173: '--quiet': 'none', 174: '--silent': 'none', 175: '-n': 'none', 176: '--regexp-extended': 'none', 177: '-r': 'none', 178: '--posix': 'none', 179: '-E': 'none', 180: '--line-length': 'number', 181: '-l': 'number', 182: '--zero-terminated': 'none', 183: '-z': 'none', 184: '--separate': 'none', 185: '-s': 'none', 186: '--unbuffered': 'none', 187: '-u': 'none', 188: '--debug': 'none', 189: '--help': 'none', 190: '--version': 'none', 191: }, 192: additionalCommandIsDangerousCallback: ( 193: rawCommand: string, 194: _args: string[], 195: ) => !sedCommandIsAllowedByAllowlist(rawCommand), 196: }, 197: sort: { 198: safeFlags: { 199: '--ignore-leading-blanks': 'none', 200: '-b': 'none', 201: '--dictionary-order': 'none', 202: '-d': 'none', 203: '--ignore-case': 'none', 204: '-f': 'none', 205: '--general-numeric-sort': 'none', 206: '-g': 'none', 207: '--human-numeric-sort': 'none', 208: '-h': 'none', 209: '--ignore-nonprinting': 'none', 210: '-i': 'none', 211: '--month-sort': 'none', 212: '-M': 'none', 213: '--numeric-sort': 'none', 214: '-n': 'none', 215: '--random-sort': 'none', 216: '-R': 'none', 217: '--reverse': 'none', 218: '-r': 'none', 219: '--sort': 'string', 220: '--stable': 'none', 221: '-s': 'none', 222: '--unique': 'none', 223: '-u': 'none', 224: '--version-sort': 'none', 225: '-V': 'none', 226: '--zero-terminated': 'none', 227: '-z': 'none', 228: '--key': 'string', 229: '-k': 'string', 230: '--field-separator': 'string', 231: '-t': 'string', 232: '--check': 'none', 233: '-c': 'none', 234: '--check-char-order': 'none', 235: '-C': 'none', 236: '--merge': 'none', 237: '-m': 'none', 238: '--buffer-size': 'string', 239: '-S': 'string', 240: '--parallel': 'number', 241: '--batch-size': 'number', 242: '--help': 'none', 243: '--version': 'none', 244: }, 245: }, 246: man: { 247: safeFlags: { 248: '-a': 'none', 249: '--all': 'none', 250: '-d': 'none', 251: '-f': 'none', 252: '--whatis': 'none', 253: '-h': 'none', 254: '-k': 'none', 255: '--apropos': 'none', 256: '-l': 'string', 257: '-w': 'none', 258: '-S': 'string', 259: '-s': 'string', 260: }, 261: }, 262: help: { 263: safeFlags: { 264: '-d': 'none', 265: '-m': 'none', 266: '-s': 'none', 267: }, 268: }, 269: netstat: { 270: safeFlags: { 271: '-a': 'none', 272: '-L': 'none', 273: '-l': 'none', 274: '-n': 'none', 275: '-f': 'string', 276: '-g': 'none', 277: '-i': 'none', 278: '-I': 'string', 279: '-s': 'none', 280: '-r': 'none', 281: '-m': 'none', 282: '-v': 'none', 283: }, 284: }, 285: ps: { 286: safeFlags: { 287: '-e': 'none', 288: '-A': 'none', 289: '-a': 'none', 290: '-d': 'none', 291: '-N': 'none', 292: '--deselect': 'none', 293: '-f': 'none', 294: '-F': 'none', 295: '-l': 'none', 296: '-j': 'none', 297: '-y': 'none', 298: '-w': 'none', 299: '-ww': 'none', 300: '--width': 'number', 301: '-c': 'none', 302: '-H': 'none', 303: '--forest': 'none', 304: '--headers': 'none', 305: '--no-headers': 'none', 306: '-n': 'string', 307: '--sort': 'string', 308: '-L': 'none', 309: '-T': 'none', 310: '-m': 'none', 311: '-C': 'string', 312: '-G': 'string', 313: '-g': 'string', 314: '-p': 'string', 315: '--pid': 'string', 316: '-q': 'string', 317: '--quick-pid': 'string', 318: '-s': 'string', 319: '--sid': 'string', 320: '-t': 'string', 321: '--tty': 'string', 322: '-U': 'string', 323: '-u': 'string', 324: '--user': 'string', 325: '--help': 'none', 326: '--info': 'none', 327: '-V': 'none', 328: '--version': 'none', 329: }, 330: additionalCommandIsDangerousCallback: ( 331: _rawCommand: string, 332: args: string[], 333: ) => { 334: return args.some( 335: a => !a.startsWith('-') && /^[a-zA-Z]*e[a-zA-Z]*$/.test(a), 336: ) 337: }, 338: }, 339: base64: { 340: respectsDoubleDash: false, 341: safeFlags: { 342: '-d': 'none', 343: '-D': 'none', 344: '--decode': 'none', 345: '-b': 'number', 346: '--break': 'number', 347: '-w': 'number', 348: '--wrap': 'number', 349: '-i': 'string', 350: '--input': 'string', 351: '--ignore-garbage': 'none', 352: '-h': 'none', 353: '--help': 'none', 354: '--version': 'none', 355: }, 356: }, 357: grep: { 358: safeFlags: { 359: '-e': 'string', 360: '--regexp': 'string', 361: '-f': 'string', 362: '--file': 'string', 363: '-F': 'none', 364: '--fixed-strings': 'none', 365: '-G': 'none', 366: '--basic-regexp': 'none', 367: '-E': 'none', 368: '--extended-regexp': 'none', 369: '-P': 'none', 370: '--perl-regexp': 'none', 371: '-i': 'none', 372: '--ignore-case': 'none', 373: '--no-ignore-case': 'none', 374: '-v': 'none', 375: '--invert-match': 'none', 376: '-w': 'none', 377: '--word-regexp': 'none', 378: '-x': 'none', 379: '--line-regexp': 'none', 380: '-c': 'none', 381: '--count': 'none', 382: '--color': 'string', 383: '--colour': 'string', 384: '-L': 'none', 385: '--files-without-match': 'none', 386: '-l': 'none', 387: '--files-with-matches': 'none', 388: '-m': 'number', 389: '--max-count': 'number', 390: '-o': 'none', 391: '--only-matching': 'none', 392: '-q': 'none', 393: '--quiet': 'none', 394: '--silent': 'none', 395: '-s': 'none', 396: '--no-messages': 'none', 397: '-b': 'none', 398: '--byte-offset': 'none', 399: '-H': 'none', 400: '--with-filename': 'none', 401: '-h': 'none', 402: '--no-filename': 'none', 403: '--label': 'string', 404: '-n': 'none', 405: '--line-number': 'none', 406: '-T': 'none', 407: '--initial-tab': 'none', 408: '-u': 'none', 409: '--unix-byte-offsets': 'none', 410: '-Z': 'none', 411: '--null': 'none', 412: '-z': 'none', 413: '--null-data': 'none', 414: '-A': 'number', 415: '--after-context': 'number', 416: '-B': 'number', 417: '--before-context': 'number', 418: '-C': 'number', 419: '--context': 'number', 420: '--group-separator': 'string', 421: '--no-group-separator': 'none', 422: '-a': 'none', 423: '--text': 'none', 424: '--binary-files': 'string', 425: '-D': 'string', 426: '--devices': 'string', 427: '-d': 'string', 428: '--directories': 'string', 429: '--exclude': 'string', 430: '--exclude-from': 'string', 431: '--exclude-dir': 'string', 432: '--include': 'string', 433: '-r': 'none', 434: '--recursive': 'none', 435: '-R': 'none', 436: '--dereference-recursive': 'none', 437: '--line-buffered': 'none', 438: '-U': 'none', 439: '--binary': 'none', 440: '--help': 'none', 441: '-V': 'none', 442: '--version': 'none', 443: }, 444: }, 445: ...RIPGREP_READ_ONLY_COMMANDS, 446: sha256sum: { 447: safeFlags: { 448: '-b': 'none', 449: '--binary': 'none', 450: '-t': 'none', 451: '--text': 'none', 452: '-c': 'none', 453: '--check': 'none', 454: '--ignore-missing': 'none', 455: '--quiet': 'none', 456: '--status': 'none', 457: '--strict': 'none', 458: '-w': 'none', 459: '--warn': 'none', 460: '--tag': 'none', 461: '-z': 'none', 462: '--zero': 'none', 463: '--help': 'none', 464: '--version': 'none', 465: }, 466: }, 467: sha1sum: { 468: safeFlags: { 469: '-b': 'none', 470: '--binary': 'none', 471: '-t': 'none', 472: '--text': 'none', 473: '-c': 'none', 474: '--check': 'none', 475: '--ignore-missing': 'none', 476: '--quiet': 'none', 477: '--status': 'none', 478: '--strict': 'none', 479: '-w': 'none', 480: '--warn': 'none', 481: '--tag': 'none', 482: '-z': 'none', 483: '--zero': 'none', 484: '--help': 'none', 485: '--version': 'none', 486: }, 487: }, 488: md5sum: { 489: safeFlags: { 490: '-b': 'none', 491: '--binary': 'none', 492: '-t': 'none', 493: '--text': 'none', 494: '-c': 'none', 495: '--check': 'none', 496: '--ignore-missing': 'none', 497: '--quiet': 'none', 498: '--status': 'none', 499: '--strict': 'none', 500: '-w': 'none', 501: '--warn': 'none', 502: '--tag': 'none', 503: '-z': 'none', 504: '--zero': 'none', 505: '--help': 'none', 506: '--version': 'none', 507: }, 508: }, 509: tree: { 510: safeFlags: { 511: '-a': 'none', 512: '-d': 'none', 513: '-l': 'none', 514: '-f': 'none', 515: '-x': 'none', 516: '-L': 'number', 517: '-P': 'string', 518: '-I': 'string', 519: '--gitignore': 'none', 520: '--gitfile': 'string', 521: '--ignore-case': 'none', 522: '--matchdirs': 'none', 523: '--metafirst': 'none', 524: '--prune': 'none', 525: '--info': 'none', 526: '--infofile': 'string', 527: '--noreport': 'none', 528: '--charset': 'string', 529: '--filelimit': 'number', 530: '-q': 'none', 531: '-N': 'none', 532: '-Q': 'none', 533: '-p': 'none', 534: '-u': 'none', 535: '-g': 'none', 536: '-s': 'none', 537: '-h': 'none', 538: '--si': 'none', 539: '--du': 'none', 540: '-D': 'none', 541: '--timefmt': 'string', 542: '-F': 'none', 543: '--inodes': 'none', 544: '--device': 'none', 545: '-v': 'none', 546: '-t': 'none', 547: '-c': 'none', 548: '-U': 'none', 549: '-r': 'none', 550: '--dirsfirst': 'none', 551: '--filesfirst': 'none', 552: '--sort': 'string', 553: '-i': 'none', 554: '-A': 'none', 555: '-S': 'none', 556: '-n': 'none', 557: '-C': 'none', 558: '-X': 'none', 559: '-J': 'none', 560: '-H': 'string', 561: '--nolinks': 'none', 562: '--hintro': 'string', 563: '--houtro': 'string', 564: '-T': 'string', 565: '--hyperlink': 'none', 566: '--scheme': 'string', 567: '--authority': 'string', 568: '--fromfile': 'none', 569: '--fromtabfile': 'none', 570: '--fflinks': 'none', 571: '--help': 'none', 572: '--version': 'none', 573: }, 574: }, 575: date: { 576: safeFlags: { 577: '-d': 'string', 578: '--date': 'string', 579: '-r': 'string', 580: '--reference': 'string', 581: '-u': 'none', 582: '--utc': 'none', 583: '--universal': 'none', 584: '-I': 'none', 585: '--iso-8601': 'string', 586: '-R': 'none', 587: '--rfc-email': 'none', 588: '--rfc-3339': 'string', 589: '--debug': 'none', 590: '--help': 'none', 591: '--version': 'none', 592: }, 593: additionalCommandIsDangerousCallback: ( 594: _rawCommand: string, 595: args: string[], 596: ) => { 597: const flagsWithArgs = new Set([ 598: '-d', 599: '--date', 600: '-r', 601: '--reference', 602: '--iso-8601', 603: '--rfc-3339', 604: ]) 605: let i = 0 606: while (i < args.length) { 607: const token = args[i]! 608: if (token.startsWith('--') && token.includes('=')) { 609: i++ 610: } else if (token.startsWith('-')) { 611: if (flagsWithArgs.has(token)) { 612: i += 2 613: } else { 614: i++ 615: } 616: } else { 617: if (!token.startsWith('+')) { 618: return true 619: } 620: i++ 621: } 622: } 623: return false 624: }, 625: }, 626: hostname: { 627: safeFlags: { 628: '-f': 'none', 629: '--fqdn': 'none', 630: '--long': 'none', 631: '-s': 'none', 632: '--short': 'none', 633: '-i': 'none', 634: '--ip-address': 'none', 635: '-I': 'none', 636: '--all-ip-addresses': 'none', 637: '-a': 'none', 638: '--alias': 'none', 639: '-d': 'none', 640: '--domain': 'none', 641: '-A': 'none', 642: '--all-fqdns': 'none', 643: '-v': 'none', 644: '--verbose': 'none', 645: '-h': 'none', 646: '--help': 'none', 647: '-V': 'none', 648: '--version': 'none', 649: }, 650: regex: /^hostname(?:\s+(?:-[a-zA-Z]|--[a-zA-Z-]+))*\s*$/, 651: }, 652: info: { 653: safeFlags: { 654: '-f': 'string', 655: '--file': 'string', 656: '-d': 'string', 657: '--directory': 'string', 658: '-n': 'string', 659: '--node': 'string', 660: '-a': 'none', 661: '--all': 'none', 662: '-k': 'string', 663: '--apropos': 'string', 664: '-w': 'none', 665: '--where': 'none', 666: '--location': 'none', 667: '--show-options': 'none', 668: '--vi-keys': 'none', 669: '--subnodes': 'none', 670: '-h': 'none', 671: '--help': 'none', 672: '--usage': 'none', 673: '--version': 'none', 674: }, 675: }, 676: lsof: { 677: safeFlags: { 678: '-?': 'none', 679: '-h': 'none', 680: '-v': 'none', 681: '-a': 'none', 682: '-b': 'none', 683: '-C': 'none', 684: '-l': 'none', 685: '-n': 'none', 686: '-N': 'none', 687: '-O': 'none', 688: '-P': 'none', 689: '-Q': 'none', 690: '-R': 'none', 691: '-t': 'none', 692: '-U': 'none', 693: '-V': 'none', 694: '-X': 'none', 695: '-H': 'none', 696: '-E': 'none', 697: '-F': 'none', 698: '-g': 'none', 699: '-i': 'none', 700: '-K': 'none', 701: '-L': 'none', 702: '-o': 'none', 703: '-r': 'none', 704: '-s': 'none', 705: '-S': 'none', 706: '-T': 'none', 707: '-x': 'none', 708: '-A': 'string', 709: '-c': 'string', 710: '-d': 'string', 711: '-e': 'string', 712: '-k': 'string', 713: '-p': 'string', 714: '-u': 'string', 715: }, 716: additionalCommandIsDangerousCallback: (_rawCommand, args) => 717: args.some(a => a === '+m' || a.startsWith('+m')), 718: }, 719: pgrep: { 720: safeFlags: { 721: '-d': 'string', 722: '--delimiter': 'string', 723: '-l': 'none', 724: '--list-name': 'none', 725: '-a': 'none', 726: '--list-full': 'none', 727: '-v': 'none', 728: '--inverse': 'none', 729: '-w': 'none', 730: '--lightweight': 'none', 731: '-c': 'none', 732: '--count': 'none', 733: '-f': 'none', 734: '--full': 'none', 735: '-g': 'string', 736: '--pgroup': 'string', 737: '-G': 'string', 738: '--group': 'string', 739: '-i': 'none', 740: '--ignore-case': 'none', 741: '-n': 'none', 742: '--newest': 'none', 743: '-o': 'none', 744: '--oldest': 'none', 745: '-O': 'string', 746: '--older': 'string', 747: '-P': 'string', 748: '--parent': 'string', 749: '-s': 'string', 750: '--session': 'string', 751: '-t': 'string', 752: '--terminal': 'string', 753: '-u': 'string', 754: '--euid': 'string', 755: '-U': 'string', 756: '--uid': 'string', 757: '-x': 'none', 758: '--exact': 'none', 759: '-F': 'string', 760: '--pidfile': 'string', 761: '-L': 'none', 762: '--logpidfile': 'none', 763: '-r': 'string', 764: '--runstates': 'string', 765: '--ns': 'string', 766: '--nslist': 'string', 767: '--help': 'none', 768: '-V': 'none', 769: '--version': 'none', 770: }, 771: }, 772: tput: { 773: safeFlags: { 774: '-T': 'string', 775: '-V': 'none', 776: '-x': 'none', 777: }, 778: additionalCommandIsDangerousCallback: ( 779: _rawCommand: string, 780: args: string[], 781: ) => { 782: const DANGEROUS_CAPABILITIES = new Set([ 783: 'init', 784: 'reset', 785: 'rs1', 786: 'rs2', 787: 'rs3', 788: 'is1', 789: 'is2', 790: 'is3', 791: 'iprog', 792: 'if', 793: 'rf', 794: 'clear', 795: 'flash', 796: 'mc0', 797: 'mc4', 798: 'mc5', 799: 'mc5i', 800: 'mc5p', 801: 'pfkey', 802: 'pfloc', 803: 'pfx', 804: 'pfxl', 805: 'smcup', 806: 'rmcup', 807: ]) 808: const flagsWithArgs = new Set(['-T']) 809: let i = 0 810: let afterDoubleDash = false 811: while (i < args.length) { 812: const token = args[i]! 813: if (token === '--') { 814: afterDoubleDash = true 815: i++ 816: } else if (!afterDoubleDash && token.startsWith('-')) { 817: if (token === '-S') return true 818: if ( 819: !token.startsWith('--') && 820: token.length > 2 && 821: token.includes('S') 822: ) 823: return true 824: if (flagsWithArgs.has(token)) { 825: i += 2 826: } else { 827: i++ 828: } 829: } else { 830: if (DANGEROUS_CAPABILITIES.has(token)) return true 831: i++ 832: } 833: } 834: return false 835: }, 836: }, 837: ss: { 838: safeFlags: { 839: '-h': 'none', 840: '--help': 'none', 841: '-V': 'none', 842: '--version': 'none', 843: '-n': 'none', 844: '--numeric': 'none', 845: '-r': 'none', 846: '--resolve': 'none', 847: '-a': 'none', 848: '--all': 'none', 849: '-l': 'none', 850: '--listening': 'none', 851: '-o': 'none', 852: '--options': 'none', 853: '-e': 'none', 854: '--extended': 'none', 855: '-m': 'none', 856: '--memory': 'none', 857: '-p': 'none', 858: '--processes': 'none', 859: '-i': 'none', 860: '--info': 'none', 861: '-s': 'none', 862: '--summary': 'none', 863: '-4': 'none', 864: '--ipv4': 'none', 865: '-6': 'none', 866: '--ipv6': 'none', 867: '-0': 'none', 868: '--packet': 'none', 869: '-t': 'none', 870: '--tcp': 'none', 871: '-M': 'none', 872: '--mptcp': 'none', 873: '-S': 'none', 874: '--sctp': 'none', 875: '-u': 'none', 876: '--udp': 'none', 877: '-d': 'none', 878: '--dccp': 'none', 879: '-w': 'none', 880: '--raw': 'none', 881: '-x': 'none', 882: '--unix': 'none', 883: '--tipc': 'none', 884: '--vsock': 'none', 885: '-f': 'string', 886: '--family': 'string', 887: '-A': 'string', 888: '--query': 'string', 889: '--socket': 'string', 890: '-Z': 'none', 891: '--context': 'none', 892: '-z': 'none', 893: '--contexts': 'none', 894: '-b': 'none', 895: '--bpf': 'none', 896: '-E': 'none', 897: '--events': 'none', 898: '-H': 'none', 899: '--no-header': 'none', 900: '-O': 'none', 901: '--oneline': 'none', 902: '--tipcinfo': 'none', 903: '--tos': 'none', 904: '--cgroup': 'none', 905: '--inet-sockopt': 'none', 906: }, 907: }, 908: fd: { safeFlags: { ...FD_SAFE_FLAGS } }, 909: fdfind: { safeFlags: { ...FD_SAFE_FLAGS } }, 910: ...PYRIGHT_READ_ONLY_COMMANDS, 911: ...DOCKER_READ_ONLY_COMMANDS, 912: } 913: const ANT_ONLY_COMMAND_ALLOWLIST: Record<string, CommandConfig> = { 914: ...GH_READ_ONLY_COMMANDS, 915: aki: { 916: safeFlags: { 917: '-h': 'none', 918: '--help': 'none', 919: '-k': 'none', 920: '--keyword': 'none', 921: '-s': 'none', 922: '--semantic': 'none', 923: '--no-adaptive': 'none', 924: '-n': 'number', 925: '--limit': 'number', 926: '-o': 'number', 927: '--offset': 'number', 928: '--source': 'string', 929: '--exclude-source': 'string', 930: '-a': 'string', 931: '--after': 'string', 932: '-b': 'string', 933: '--before': 'string', 934: '--collection': 'string', 935: '--drive': 'string', 936: '--folder': 'string', 937: '--descendants': 'none', 938: '-m': 'string', 939: '--meta': 'string', 940: '-t': 'string', 941: '--threshold': 'string', 942: '--kw-weight': 'string', 943: '--sem-weight': 'string', 944: '-j': 'none', 945: '--json': 'none', 946: '-c': 'none', 947: '--chunk': 'none', 948: '--preview': 'none', 949: '-d': 'none', 950: '--full-doc': 'none', 951: '-v': 'none', 952: '--verbose': 'none', 953: '--stats': 'none', 954: '-S': 'number', 955: '--summarize': 'number', 956: '--explain': 'none', 957: '--examine': 'string', 958: '--url': 'string', 959: '--multi-turn': 'number', 960: '--multi-turn-model': 'string', 961: '--multi-turn-context': 'string', 962: '--no-rerank': 'none', 963: '--audit': 'none', 964: '--local': 'none', 965: '--staging': 'none', 966: }, 967: }, 968: } 969: function getCommandAllowlist(): Record<string, CommandConfig> { 970: let allowlist: Record<string, CommandConfig> = COMMAND_ALLOWLIST 971: if (getPlatform() === 'windows') { 972: const { xargs: _, ...rest } = allowlist 973: allowlist = rest 974: } 975: if (process.env.USER_TYPE === 'ant') { 976: return { ...allowlist, ...ANT_ONLY_COMMAND_ALLOWLIST } 977: } 978: return allowlist 979: } 980: const SAFE_TARGET_COMMANDS_FOR_XARGS = [ 981: 'echo', 982: 'printf', 983: 'wc', 984: 'grep', 985: 'head', 986: 'tail', 987: ] 988: export function isCommandSafeViaFlagParsing(command: string): boolean { 989: const parseResult = tryParseShellCommand(command, env => `$${env}`) 990: if (!parseResult.success) return false 991: const parsed = parseResult.tokens.map(token => { 992: if (typeof token !== 'string') { 993: token = token as { op: 'glob'; pattern: string } 994: if (token.op === 'glob') { 995: return token.pattern 996: } 997: } 998: return token 999: }) 1000: const hasOperators = parsed.some(token => typeof token !== 'string') 1001: if (hasOperators) { 1002: return false 1003: } 1004: const tokens = parsed as string[] 1005: if (tokens.length === 0) { 1006: return false 1007: } 1008: let commandConfig: CommandConfig | undefined 1009: let commandTokens: number = 0 1010: const allowlist = getCommandAllowlist() 1011: for (const [cmdPattern] of Object.entries(allowlist)) { 1012: const cmdTokens = cmdPattern.split(' ') 1013: if (tokens.length >= cmdTokens.length) { 1014: let matches = true 1015: for (let i = 0; i < cmdTokens.length; i++) { 1016: if (tokens[i] !== cmdTokens[i]) { 1017: matches = false 1018: break 1019: } 1020: } 1021: if (matches) { 1022: commandConfig = allowlist[cmdPattern] 1023: commandTokens = cmdTokens.length 1024: break 1025: } 1026: } 1027: } 1028: if (!commandConfig) { 1029: return false 1030: } 1031: if (tokens[0] === 'git' && tokens[1] === 'ls-remote') { 1032: for (let i = 2; i < tokens.length; i++) { 1033: const token = tokens[i] 1034: if (token && !token.startsWith('-')) { 1035: if (token.includes('://')) { 1036: return false 1037: } 1038: if (token.includes('@') || token.includes(':')) { 1039: return false 1040: } 1041: if (token.includes('$')) { 1042: return false 1043: } 1044: } 1045: } 1046: } 1047: for (let i = commandTokens; i < tokens.length; i++) { 1048: const token = tokens[i] 1049: if (!token) continue 1050: if (token.includes('$')) { 1051: return false 1052: } 1053: if (token.includes('{') && (token.includes(',') || token.includes('..'))) { 1054: return false 1055: } 1056: } 1057: if ( 1058: !validateFlags(tokens, commandTokens, commandConfig, { 1059: commandName: tokens[0], 1060: rawCommand: command, 1061: xargsTargetCommands: 1062: tokens[0] === 'xargs' ? SAFE_TARGET_COMMANDS_FOR_XARGS : undefined, 1063: }) 1064: ) { 1065: return false 1066: } 1067: if (commandConfig.regex && !commandConfig.regex.test(command)) { 1068: return false 1069: } 1070: if (!commandConfig.regex && /`/.test(command)) { 1071: return false 1072: } 1073: // Block newlines and carriage returns in grep/rg patterns as they can be used for injection 1074: if ( 1075: !commandConfig.regex && 1076: (tokens[0] === 'rg' || tokens[0] === 'grep') && 1077: /[\n\r]/.test(command) 1078: ) { 1079: return false 1080: } 1081: if ( 1082: commandConfig.additionalCommandIsDangerousCallback && 1083: commandConfig.additionalCommandIsDangerousCallback( 1084: command, 1085: tokens.slice(commandTokens), 1086: ) 1087: ) { 1088: return false 1089: } 1090: return true 1091: } 1092: /** 1093: * Creates a regex pattern that matches safe invocations of a command. 1094: * 1095: * The regex ensures commands are invoked safely by blocking: 1096: * - Shell metacharacters that could lead to command injection or redirection 1097: * - Command substitution via backticks or $() 1098: * - Variable expansion that could contain malicious payloads 1099: * - Environment variable assignment bypasses (command=value) 1100: * 1101: * @param command The command name (e.g., 'date', 'npm list', 'ip addr') 1102: * @returns RegExp that matches safe invocations of the command 1103: */ 1104: function makeRegexForSafeCommand(command: string): RegExp { 1105: // Create regex pattern: /^command(?:\s|$)[^<>()$`|{}&;\n\r]*$/ 1106: return new RegExp(`^${command}(?:\\s|$)[^<>()$\`|{}&;\\n\\r]*$`) 1107: } 1108: const READONLY_COMMANDS = [ 1109: ...EXTERNAL_READONLY_COMMANDS, 1110: 'cal', 1111: 'uptime', 1112: 'cat', 1113: 'head', 1114: 'tail', 1115: 'wc', 1116: 'stat', 1117: 'strings', 1118: 'hexdump', 1119: 'od', 1120: 'nl', 1121: 'id', 1122: 'uname', 1123: 'free', 1124: 'df', 1125: 'du', 1126: 'locale', 1127: 'groups', 1128: 'nproc', 1129: 'basename', 1130: 'dirname', 1131: 'realpath', 1132: 'cut', 1133: 'paste', 1134: 'tr', 1135: 'column', 1136: 'tac', 1137: 'rev', 1138: 'fold', 1139: 'expand', 1140: 'unexpand', 1141: 'fmt', 1142: 'comm', 1143: 'cmp', 1144: 'numfmt', 1145: 'readlink', 1146: 'diff', 1147: 'true', 1148: 'false', 1149: 'sleep', 1150: 'which', 1151: 'type', 1152: 'expr', 1153: 'test', 1154: 'getconf', 1155: 'seq', 1156: 'tsort', 1157: 'pr', 1158: ] 1159: const READONLY_COMMAND_REGEXES = new Set([ 1160: ...READONLY_COMMANDS.map(makeRegexForSafeCommand), 1161: /^echo(?:\s+(?:'[^']*'|"[^"$<>\n\r]*"|[^|;&`$(){}><#\\!"'\s]+))*(?:\s+2>&1)?\s*$/, 1162: /^claude -h$/, 1163: /^claude --help$/, 1164: /^uniq(?:\s+(?:-[a-zA-Z]+|--[a-zA-Z-]+(?:=\S+)?|-[fsw]\s+\d+))*(?:\s|$)\s*$/, 1165: /^pwd$/, 1166: /^whoami$/, 1167: /^node -v$/, 1168: /^node --version$/, 1169: /^python --version$/, 1170: /^python3 --version$/, 1171: /^history(?:\s+\d+)?\s*$/, 1172: /^alias$/, 1173: /^arch(?:\s+(?:--help|-h))?\s*$/, 1174: /^ip addr$/, 1175: /^ifconfig(?:\s+[a-zA-Z][a-zA-Z0-9_-]*)?\s*$/, 1176: /^jq(?!\s+.*(?:-f\b|--from-file|--rawfile|--slurpfile|--run-tests|-L\b|--library-path|\benv\b|\$ENV\b))(?:\s+(?:-[a-zA-Z]+|--[a-zA-Z-]+(?:=\S+)?))*(?:\s+'[^'`]*'|\s+"[^"`]*"|\s+[^-\s'"][^\s]*)+\s*$/, 1177: /^cd(?:\s+(?:'[^']*'|"[^"]*"|[^\s;|&`$(){}><#\\]+))?$/, 1178: // ls command - allows listing directories 1179: /^ls(?:\s+[^<>()$`|{}&;\n\r]*)?$/, 1180: // find command - blocks dangerous flags 1181: // Allow escaped parentheses \( and \) for grouping, but block unescaped ones 1182: // NOTE: \\[()] must come BEFORE the character class to ensure \( is matched as an escaped paren, 1183: // not as backslash + paren (which would fail since paren is excluded from the character class) 1184: /^find(?:\s+(?:\\[()]|(?!-delete\b|-exec\b|-execdir\b|-ok\b|-okdir\b|-fprint0?\b|-fls\b|-fprintf\b)[^<>()$`|{}&;\n\r\s]|\s)+)?$/, 1185: ]) 1186: /** 1187: * Checks if a command contains glob characters (?, *, [, ]) or expandable `$` 1188: * variables OUTSIDE the quote contexts where bash would treat them as literal. 1189: * These could expand to bypass our regex-based security checks. 1190: * 1191: * Glob examples: 1192: * - `python *` could expand to `python --help` if a file named `--help` exists 1193: * - `find ./ -?xec` could expand to `find ./ -exec` if such a file exists 1194: * Globs are literal inside BOTH single and double quotes. 1195: * 1196: * Variable expansion examples: 1197: * - `uniq --skip-chars=0$_` → `$_` expands to last arg of previous command; 1198: * with IFS word splitting, this smuggles positional args past "flags-only" 1199: * regexes. `echo " /etc/passwd /tmp/x"; uniq --skip-chars=0$_` → FILE WRITE. 1200: * - `cd "$HOME"` → double-quoted `$HOME` expands at runtime. 1201: * Variables are literal ONLY inside single quotes; they expand inside double 1202: * quotes and unquoted. 1203: * 1204: * The `$` check guards the READONLY_COMMAND_REGEXES fallback path. The `$` 1205: * token check in isCommandSafeViaFlagParsing only covers COMMAND_ALLOWLIST 1206: * commands; hand-written regexes like uniq's `\S+` and cd's `"[^"]*"` allow `$`. 1207: * Matches `$` followed by `[A-Za-z_@*#?!$0-9-]` covering `$VAR`, `$_`, `$@`, 1208: * `$*`, `$#`, `$?`, `$!`, `$$`, `$-`, `$0`-`$9`. Does NOT match `${` or `$(` — 1209: * those are caught by COMMAND_SUBSTITUTION_PATTERNS in bashSecurity.ts. 1210: * 1211: * @param command The command string to check 1212: * @returns true if the command contains unquoted glob or expandable `$` 1213: */ 1214: function containsUnquotedExpansion(command: string): boolean { 1215: let inSingleQuote = false 1216: let inDoubleQuote = false 1217: let escaped = false 1218: for (let i = 0; i < command.length; i++) { 1219: const currentChar = command[i] 1220: if (escaped) { 1221: escaped = false 1222: continue 1223: } 1224: if (currentChar === '\\' && !inSingleQuote) { 1225: escaped = true 1226: continue 1227: } 1228: // Update quote state 1229: if (currentChar === "'" && !inDoubleQuote) { 1230: inSingleQuote = !inSingleQuote 1231: continue 1232: } 1233: if (currentChar === '"' && !inSingleQuote) { 1234: inDoubleQuote = !inDoubleQuote 1235: continue 1236: } 1237: // Inside single quotes: everything is literal. Skip. 1238: if (inSingleQuote) { 1239: continue 1240: } 1241: // Check `$` followed by variable-name or special-parameter character. 1242: // `$` expands inside double quotes AND unquoted (only SQ makes it literal). 1243: if (currentChar === '$') { 1244: const next = command[i + 1] 1245: if (next && /[A-Za-z_@*#?!$0-9-]/.test(next)) { 1246: return true 1247: } 1248: } 1249: // Globs are literal inside double quotes too. Only check unquoted. 1250: if (inDoubleQuote) { 1251: continue 1252: } 1253: // Check for glob characters outside all quotes. 1254: // These could expand to anything, including dangerous flags. 1255: if (currentChar && /[?*[\]]/.test(currentChar)) { 1256: return true 1257: } 1258: } 1259: return false 1260: } 1261: /** 1262: * Checks if a single command string is read-only based on READONLY_COMMAND_REGEXES. 1263: * Internal helper function that validates individual commands. 1264: * 1265: * @param command The command string to check 1266: * @returns true if the command is read-only 1267: */ 1268: function isCommandReadOnly(command: string): boolean { 1269: // Handle common stderr-to-stdout redirection pattern 1270: // This handles both "command 2>&1" at the end of a full command 1271: // and "command 2>&1" as part of a pipeline component 1272: let testCommand = command.trim() 1273: if (testCommand.endsWith(' 2>&1')) { 1274: testCommand = testCommand.slice(0, -5).trim() 1275: } 1276: if (containsVulnerableUncPath(testCommand)) { 1277: return false 1278: } 1279: if (containsUnquotedExpansion(testCommand)) { 1280: return false 1281: } 1282: if (isCommandSafeViaFlagParsing(testCommand)) { 1283: return true 1284: } 1285: for (const regex of READONLY_COMMAND_REGEXES) { 1286: if (regex.test(testCommand)) { 1287: if (testCommand.includes('git') && /\s-c[\s=]/.test(testCommand)) { 1288: return false 1289: } 1290: if ( 1291: testCommand.includes('git') && 1292: /\s--exec-path[\s=]/.test(testCommand) 1293: ) { 1294: return false 1295: } 1296: if ( 1297: testCommand.includes('git') && 1298: /\s--config-env[\s=]/.test(testCommand) 1299: ) { 1300: return false 1301: } 1302: return true 1303: } 1304: } 1305: return false 1306: } 1307: function commandHasAnyGit(command: string): boolean { 1308: return splitCommand_DEPRECATED(command).some(subcmd => 1309: isNormalizedGitCommand(subcmd.trim()), 1310: ) 1311: } 1312: const GIT_INTERNAL_PATTERNS = [ 1313: /^HEAD$/, 1314: /^objects(?:\/|$)/, 1315: /^refs(?:\/|$)/, 1316: /^hooks(?:\/|$)/, 1317: ] 1318: function isGitInternalPath(path: string): boolean { 1319: const normalized = path.replace(/^\.?\//, '') 1320: return GIT_INTERNAL_PATTERNS.some(pattern => pattern.test(normalized)) 1321: } 1322: // Commands that only delete or modify in-place (don't create new files at new paths) 1323: const NON_CREATING_WRITE_COMMANDS = new Set(['rm', 'rmdir', 'sed']) 1324: function extractWritePathsFromSubcommand(subcommand: string): string[] { 1325: const parseResult = tryParseShellCommand(subcommand, env => `$${env}`) 1326: if (!parseResult.success) return [] 1327: const tokens = parseResult.tokens.filter( 1328: (t): t is string => typeof t === 'string', 1329: ) 1330: if (tokens.length === 0) return [] 1331: const baseCmd = tokens[0] 1332: if (!baseCmd) return [] 1333: if (!(baseCmd in COMMAND_OPERATION_TYPE)) { 1334: return [] 1335: } 1336: const opType = COMMAND_OPERATION_TYPE[baseCmd as PathCommand] 1337: if ( 1338: (opType !== 'write' && opType !== 'create') || 1339: NON_CREATING_WRITE_COMMANDS.has(baseCmd) 1340: ) { 1341: return [] 1342: } 1343: const extractor = PATH_EXTRACTORS[baseCmd as PathCommand] 1344: if (!extractor) return [] 1345: return extractor(tokens.slice(1)) 1346: } 1347: function commandWritesToGitInternalPaths(command: string): boolean { 1348: const subcommands = splitCommand_DEPRECATED(command) 1349: for (const subcmd of subcommands) { 1350: const trimmed = subcmd.trim() 1351: const writePaths = extractWritePathsFromSubcommand(trimmed) 1352: for (const path of writePaths) { 1353: if (isGitInternalPath(path)) { 1354: return true 1355: } 1356: } 1357: const { redirections } = extractOutputRedirections(trimmed) 1358: for (const { target } of redirections) { 1359: if (isGitInternalPath(target)) { 1360: return true 1361: } 1362: } 1363: } 1364: return false 1365: } 1366: export function checkReadOnlyConstraints( 1367: input: z.infer<typeof BashTool.inputSchema>, 1368: compoundCommandHasCd: boolean, 1369: ): PermissionResult { 1370: const { command } = input 1371: const result = tryParseShellCommand(command, env => `$${env}`) 1372: if (!result.success) { 1373: return { 1374: behavior: 'passthrough', 1375: message: 'Command cannot be parsed, requires further permission checks', 1376: } 1377: } 1378: if (bashCommandIsSafe_DEPRECATED(command).behavior !== 'passthrough') { 1379: return { 1380: behavior: 'passthrough', 1381: message: 'Command is not read-only, requires further permission checks', 1382: } 1383: } 1384: if (containsVulnerableUncPath(command)) { 1385: return { 1386: behavior: 'ask', 1387: message: 1388: 'Command contains Windows UNC path that could be vulnerable to WebDAV attacks', 1389: } 1390: } 1391: const hasGitCommand = commandHasAnyGit(command) 1392: if (compoundCommandHasCd && hasGitCommand) { 1393: return { 1394: behavior: 'passthrough', 1395: message: 1396: 'Compound commands with cd and git require permission checks for enhanced security', 1397: } 1398: } 1399: if (hasGitCommand && isCurrentDirectoryBareGitRepo()) { 1400: return { 1401: behavior: 'passthrough', 1402: message: 1403: 'Git commands in directories with bare repository structure require permission checks for enhanced security', 1404: } 1405: } 1406: if (hasGitCommand && commandWritesToGitInternalPaths(command)) { 1407: return { 1408: behavior: 'passthrough', 1409: message: 1410: 'Compound commands that create git internal files and run git require permission checks for enhanced security', 1411: } 1412: } 1413: if ( 1414: hasGitCommand && 1415: SandboxManager.isSandboxingEnabled() && 1416: getCwd() !== getOriginalCwd() 1417: ) { 1418: return { 1419: behavior: 'passthrough', 1420: message: 1421: 'Git commands outside the original working directory require permission checks when sandbox is enabled', 1422: } 1423: } 1424: const allSubcommandsReadOnly = splitCommand_DEPRECATED(command).every( 1425: subcmd => { 1426: if (bashCommandIsSafe_DEPRECATED(subcmd).behavior !== 'passthrough') { 1427: return false 1428: } 1429: return isCommandReadOnly(subcmd) 1430: }, 1431: ) 1432: if (allSubcommandsReadOnly) { 1433: return { 1434: behavior: 'allow', 1435: updatedInput: input, 1436: } 1437: } 1438: return { 1439: behavior: 'passthrough', 1440: message: 'Command is not read-only, requires further permission checks', 1441: } 1442: }

File: src/tools/BashTool/sedEditParser.ts

typescript 1: import { randomBytes } from 'crypto' 2: import { tryParseShellCommand } from '../../utils/bash/shellQuote.js' 3: const BACKSLASH_PLACEHOLDER = '\x00BACKSLASH\x00' 4: const PLUS_PLACEHOLDER = '\x00PLUS\x00' 5: const QUESTION_PLACEHOLDER = '\x00QUESTION\x00' 6: const PIPE_PLACEHOLDER = '\x00PIPE\x00' 7: const LPAREN_PLACEHOLDER = '\x00LPAREN\x00' 8: const RPAREN_PLACEHOLDER = '\x00RPAREN\x00' 9: const BACKSLASH_PLACEHOLDER_RE = new RegExp(BACKSLASH_PLACEHOLDER, 'g') 10: const PLUS_PLACEHOLDER_RE = new RegExp(PLUS_PLACEHOLDER, 'g') 11: const QUESTION_PLACEHOLDER_RE = new RegExp(QUESTION_PLACEHOLDER, 'g') 12: const PIPE_PLACEHOLDER_RE = new RegExp(PIPE_PLACEHOLDER, 'g') 13: const LPAREN_PLACEHOLDER_RE = new RegExp(LPAREN_PLACEHOLDER, 'g') 14: const RPAREN_PLACEHOLDER_RE = new RegExp(RPAREN_PLACEHOLDER, 'g') 15: export type SedEditInfo = { 16: filePath: string 17: pattern: string 18: replacement: string 19: flags: string 20: extendedRegex: boolean 21: } 22: export function isSedInPlaceEdit(command: string): boolean { 23: const info = parseSedEditCommand(command) 24: return info !== null 25: } 26: export function parseSedEditCommand(command: string): SedEditInfo | null { 27: const trimmed = command.trim() 28: const sedMatch = trimmed.match(/^\s*sed\s+/) 29: if (!sedMatch) return null 30: const withoutSed = trimmed.slice(sedMatch[0].length) 31: const parseResult = tryParseShellCommand(withoutSed) 32: if (!parseResult.success) return null 33: const tokens = parseResult.tokens 34: const args: string[] = [] 35: for (const token of tokens) { 36: if (typeof token === 'string') { 37: args.push(token) 38: } else if ( 39: typeof token === 'object' && 40: token !== null && 41: 'op' in token && 42: token.op === 'glob' 43: ) { 44: return null 45: } 46: } 47: let hasInPlaceFlag = false 48: let extendedRegex = false 49: let expression: string | null = null 50: let filePath: string | null = null 51: let i = 0 52: while (i < args.length) { 53: const arg = args[i]! 54: if (arg === '-i' || arg === '--in-place') { 55: hasInPlaceFlag = true 56: i++ 57: if (i < args.length) { 58: const nextArg = args[i] 59: if ( 60: typeof nextArg === 'string' && 61: !nextArg.startsWith('-') && 62: (nextArg === '' || nextArg.startsWith('.')) 63: ) { 64: i++ // Skip the backup suffix 65: } 66: } 67: continue 68: } 69: if (arg.startsWith('-i')) { 70: hasInPlaceFlag = true 71: i++ 72: continue 73: } 74: if (arg === '-E' || arg === '-r' || arg === '--regexp-extended') { 75: extendedRegex = true 76: i++ 77: continue 78: } 79: if (arg === '-e' || arg === '--expression') { 80: if (i + 1 < args.length && typeof args[i + 1] === 'string') { 81: if (expression !== null) return null 82: expression = args[i + 1]! 83: i += 2 84: continue 85: } 86: return null 87: } 88: if (arg.startsWith('--expression=')) { 89: if (expression !== null) return null 90: expression = arg.slice('--expression='.length) 91: i++ 92: continue 93: } 94: if (arg.startsWith('-')) { 95: return null 96: } 97: if (expression === null) { 98: expression = arg 99: } else if (filePath === null) { 100: filePath = arg 101: } else { 102: return null 103: } 104: i++ 105: } 106: if (!hasInPlaceFlag || !expression || !filePath) { 107: return null 108: } 109: const substMatch = expression.match(/^s\//) 110: if (!substMatch) { 111: return null 112: } 113: const rest = expression.slice(2) 114: let pattern = '' 115: let replacement = '' 116: let flags = '' 117: let state: 'pattern' | 'replacement' | 'flags' = 'pattern' 118: let j = 0 119: while (j < rest.length) { 120: const char = rest[j]! 121: if (char === '\\' && j + 1 < rest.length) { 122: // Escaped character 123: if (state === 'pattern') { 124: pattern += char + rest[j + 1] 125: } else if (state === 'replacement') { 126: replacement += char + rest[j + 1] 127: } else { 128: flags += char + rest[j + 1] 129: } 130: j += 2 131: continue 132: } 133: if (char === '/') { 134: if (state === 'pattern') { 135: state = 'replacement' 136: } else if (state === 'replacement') { 137: state = 'flags' 138: } else { 139: return null 140: } 141: j++ 142: continue 143: } 144: if (state === 'pattern') { 145: pattern += char 146: } else if (state === 'replacement') { 147: replacement += char 148: } else { 149: flags += char 150: } 151: j++ 152: } 153: if (state !== 'flags') { 154: return null 155: } 156: const validFlags = /^[gpimIM1-9]*$/ 157: if (!validFlags.test(flags)) { 158: return null 159: } 160: return { 161: filePath, 162: pattern, 163: replacement, 164: flags, 165: extendedRegex, 166: } 167: } 168: export function applySedSubstitution( 169: content: string, 170: sedInfo: SedEditInfo, 171: ): string { 172: let regexFlags = '' 173: // Handle global flag 174: if (sedInfo.flags.includes('g')) { 175: regexFlags += 'g' 176: } 177: if (sedInfo.flags.includes('i') || sedInfo.flags.includes('I')) { 178: regexFlags += 'i' 179: } 180: if (sedInfo.flags.includes('m') || sedInfo.flags.includes('M')) { 181: regexFlags += 'm' 182: } 183: let jsPattern = sedInfo.pattern 184: .replace(/\\\//g, '/') 185: if (!sedInfo.extendedRegex) { 186: jsPattern = jsPattern 187: .replace(/\\\\/g, BACKSLASH_PLACEHOLDER) 188: .replace(/\\\+/g, PLUS_PLACEHOLDER) 189: .replace(/\\\?/g, QUESTION_PLACEHOLDER) 190: .replace(/\\\|/g, PIPE_PLACEHOLDER) 191: .replace(/\\\(/g, LPAREN_PLACEHOLDER) 192: .replace(/\\\)/g, RPAREN_PLACEHOLDER) 193: .replace(/\+/g, '\\+') 194: .replace(/\?/g, '\\?') 195: .replace(/\|/g, '\\|') 196: .replace(/\(/g, '\\(') 197: .replace(/\)/g, '\\)') 198: .replace(BACKSLASH_PLACEHOLDER_RE, '\\\\') 199: .replace(PLUS_PLACEHOLDER_RE, '+') 200: .replace(QUESTION_PLACEHOLDER_RE, '?') 201: .replace(PIPE_PLACEHOLDER_RE, '|') 202: .replace(LPAREN_PLACEHOLDER_RE, '(') 203: .replace(RPAREN_PLACEHOLDER_RE, ')') 204: } 205: // Unescape sed-specific escapes in replacement 206: // Convert \n to newline, & to $& (match), etc. 207: // Use a unique placeholder with random salt to prevent injection attacks 208: const salt = randomBytes(8).toString('hex') 209: const ESCAPED_AMP_PLACEHOLDER = `___ESCAPED_AMPERSAND_${salt}___` 210: const jsReplacement = sedInfo.replacement 211: .replace(/\\\//g, '/') 212: .replace(/\\&/g, ESCAPED_AMP_PLACEHOLDER) 213: .replace(/&/g, '$$&') 214: .replace(new RegExp(ESCAPED_AMP_PLACEHOLDER, 'g'), '&') 215: try { 216: const regex = new RegExp(jsPattern, regexFlags) 217: return content.replace(regex, jsReplacement) 218: } catch { 219: return content 220: } 221: }

File: src/tools/BashTool/sedValidation.ts

typescript 1: import type { ToolPermissionContext } from '../../Tool.js' 2: import { splitCommand_DEPRECATED } from '../../utils/bash/commands.js' 3: import { tryParseShellCommand } from '../../utils/bash/shellQuote.js' 4: import type { PermissionResult } from '../../utils/permissions/PermissionResult.js' 5: function validateFlagsAgainstAllowlist( 6: flags: string[], 7: allowedFlags: string[], 8: ): boolean { 9: for (const flag of flags) { 10: if (flag.startsWith('-') && !flag.startsWith('--') && flag.length > 2) { 11: for (let i = 1; i < flag.length; i++) { 12: const singleFlag = '-' + flag[i] 13: if (!allowedFlags.includes(singleFlag)) { 14: return false 15: } 16: } 17: } else { 18: if (!allowedFlags.includes(flag)) { 19: return false 20: } 21: } 22: } 23: return true 24: } 25: export function isLinePrintingCommand( 26: command: string, 27: expressions: string[], 28: ): boolean { 29: const sedMatch = command.match(/^\s*sed\s+/) 30: if (!sedMatch) return false 31: const withoutSed = command.slice(sedMatch[0].length) 32: const parseResult = tryParseShellCommand(withoutSed) 33: if (!parseResult.success) return false 34: const parsed = parseResult.tokens 35: const flags: string[] = [] 36: for (const arg of parsed) { 37: if (typeof arg === 'string' && arg.startsWith('-') && arg !== '--') { 38: flags.push(arg) 39: } 40: } 41: const allowedFlags = [ 42: '-n', 43: '--quiet', 44: '--silent', 45: '-E', 46: '--regexp-extended', 47: '-r', 48: '-z', 49: '--zero-terminated', 50: '--posix', 51: ] 52: if (!validateFlagsAgainstAllowlist(flags, allowedFlags)) { 53: return false 54: } 55: let hasNFlag = false 56: for (const flag of flags) { 57: if (flag === '-n' || flag === '--quiet' || flag === '--silent') { 58: hasNFlag = true 59: break 60: } 61: if (flag.startsWith('-') && !flag.startsWith('--') && flag.includes('n')) { 62: hasNFlag = true 63: break 64: } 65: } 66: if (!hasNFlag) { 67: return false 68: } 69: if (expressions.length === 0) { 70: return false 71: } 72: for (const expr of expressions) { 73: const commands = expr.split(';') 74: for (const cmd of commands) { 75: if (!isPrintCommand(cmd.trim())) { 76: return false 77: } 78: } 79: } 80: return true 81: } 82: export function isPrintCommand(cmd: string): boolean { 83: if (!cmd) return false 84: return /^(?:\d+|\d+,\d+)?p$/.test(cmd) 85: } 86: function isSubstitutionCommand( 87: command: string, 88: expressions: string[], 89: hasFileArguments: boolean, 90: options?: { allowFileWrites?: boolean }, 91: ): boolean { 92: const allowFileWrites = options?.allowFileWrites ?? false 93: if (!allowFileWrites && hasFileArguments) { 94: return false 95: } 96: const sedMatch = command.match(/^\s*sed\s+/) 97: if (!sedMatch) return false 98: const withoutSed = command.slice(sedMatch[0].length) 99: const parseResult = tryParseShellCommand(withoutSed) 100: if (!parseResult.success) return false 101: const parsed = parseResult.tokens 102: const flags: string[] = [] 103: for (const arg of parsed) { 104: if (typeof arg === 'string' && arg.startsWith('-') && arg !== '--') { 105: flags.push(arg) 106: } 107: } 108: const allowedFlags = ['-E', '--regexp-extended', '-r', '--posix'] 109: if (allowFileWrites) { 110: allowedFlags.push('-i', '--in-place') 111: } 112: if (!validateFlagsAgainstAllowlist(flags, allowedFlags)) { 113: return false 114: } 115: if (expressions.length !== 1) { 116: return false 117: } 118: const expr = expressions[0]!.trim() 119: if (!expr.startsWith('s')) { 120: return false 121: } 122: const substitutionMatch = expr.match(/^s\/(.*?)$/) 123: if (!substitutionMatch) { 124: return false 125: } 126: const rest = substitutionMatch[1]! 127: let delimiterCount = 0 128: let lastDelimiterPos = -1 129: let i = 0 130: while (i < rest.length) { 131: if (rest[i] === '\\') { 132: // Skip escaped character 133: i += 2 134: continue 135: } 136: if (rest[i] === '/') { 137: delimiterCount++ 138: lastDelimiterPos = i 139: } 140: i++ 141: } 142: // Must have found exactly 2 delimiters (pattern and replacement) 143: if (delimiterCount !== 2) { 144: return false 145: } 146: // Extract flags (everything after the last delimiter) 147: const exprFlags = rest.slice(lastDelimiterPos + 1) 148: // Validate flags: only allow g, p, i, I, m, M, and optionally ONE digit 1-9 149: const allowedFlagChars = /^[gpimIM]*[1-9]?[gpimIM]*$/ 150: if (!allowedFlagChars.test(exprFlags)) { 151: return false 152: } 153: return true 154: } 155: /** 156: * Checks if a sed command is allowed by the allowlist. 157: * The allowlist patterns themselves are strict enough to reject dangerous operations. 158: * @param command The sed command to check 159: * @param options.allowFileWrites When true, allows -i flag and file arguments for substitution commands 160: * @returns true if the command is allowed (matches allowlist and passes denylist check), false otherwise 161: */ 162: export function sedCommandIsAllowedByAllowlist( 163: command: string, 164: options?: { allowFileWrites?: boolean }, 165: ): boolean { 166: const allowFileWrites = options?.allowFileWrites ?? false 167: // Extract sed expressions (content inside quotes where actual sed commands live) 168: let expressions: string[] 169: try { 170: expressions = extractSedExpressions(command) 171: } catch (_error) { 172: // If parsing failed, treat as not allowed 173: return false 174: } 175: // Check if sed command has file arguments 176: const hasFileArguments = hasFileArgs(command) 177: // Check if command matches allowlist patterns 178: let isPattern1 = false 179: let isPattern2 = false 180: if (allowFileWrites) { 181: // When allowing file writes, only check substitution commands (Pattern 2 variant) 182: // Pattern 1 (line printing) doesn't need file writes 183: isPattern2 = isSubstitutionCommand(command, expressions, hasFileArguments, { 184: allowFileWrites: true, 185: }) 186: } else { 187: isPattern1 = isLinePrintingCommand(command, expressions) 188: isPattern2 = isSubstitutionCommand(command, expressions, hasFileArguments) 189: } 190: if (!isPattern1 && !isPattern2) { 191: return false 192: } 193: for (const expr of expressions) { 194: if (isPattern2 && expr.includes(';')) { 195: return false 196: } 197: } 198: for (const expr of expressions) { 199: if (containsDangerousOperations(expr)) { 200: return false 201: } 202: } 203: return true 204: } 205: export function hasFileArgs(command: string): boolean { 206: const sedMatch = command.match(/^\s*sed\s+/) 207: if (!sedMatch) return false 208: const withoutSed = command.slice(sedMatch[0].length) 209: const parseResult = tryParseShellCommand(withoutSed) 210: if (!parseResult.success) return true 211: const parsed = parseResult.tokens 212: try { 213: let argCount = 0 214: let hasEFlag = false 215: for (let i = 0; i < parsed.length; i++) { 216: const arg = parsed[i] 217: if (typeof arg !== 'string' && typeof arg !== 'object') continue 218: if ( 219: typeof arg === 'object' && 220: arg !== null && 221: 'op' in arg && 222: arg.op === 'glob' 223: ) { 224: return true 225: } 226: if (typeof arg !== 'string') continue 227: if ((arg === '-e' || arg === '--expression') && i + 1 < parsed.length) { 228: hasEFlag = true 229: i++ 230: continue 231: } 232: if (arg.startsWith('--expression=')) { 233: hasEFlag = true 234: continue 235: } 236: if (arg.startsWith('-e=')) { 237: hasEFlag = true 238: continue 239: } 240: if (arg.startsWith('-')) continue 241: argCount++ 242: if (hasEFlag) { 243: return true 244: } 245: if (argCount > 1) { 246: return true 247: } 248: } 249: return false 250: } catch (_error) { 251: return true 252: } 253: } 254: export function extractSedExpressions(command: string): string[] { 255: const expressions: string[] = [] 256: const sedMatch = command.match(/^\s*sed\s+/) 257: if (!sedMatch) return expressions 258: const withoutSed = command.slice(sedMatch[0].length) 259: if (/-e[wWe]/.test(withoutSed) || /-w[eE]/.test(withoutSed)) { 260: throw new Error('Dangerous flag combination detected') 261: } 262: const parseResult = tryParseShellCommand(withoutSed) 263: if (!parseResult.success) { 264: throw new Error(`Malformed shell syntax: ${parseResult.error}`) 265: } 266: const parsed = parseResult.tokens 267: try { 268: let foundEFlag = false 269: let foundExpression = false 270: for (let i = 0; i < parsed.length; i++) { 271: const arg = parsed[i] 272: if (typeof arg !== 'string') continue 273: if ((arg === '-e' || arg === '--expression') && i + 1 < parsed.length) { 274: foundEFlag = true 275: const nextArg = parsed[i + 1] 276: if (typeof nextArg === 'string') { 277: expressions.push(nextArg) 278: i++ 279: } 280: continue 281: } 282: if (arg.startsWith('--expression=')) { 283: foundEFlag = true 284: expressions.push(arg.slice('--expression='.length)) 285: continue 286: } 287: if (arg.startsWith('-e=')) { 288: foundEFlag = true 289: expressions.push(arg.slice('-e='.length)) 290: continue 291: } 292: if (arg.startsWith('-')) continue 293: if (!foundEFlag && !foundExpression) { 294: expressions.push(arg) 295: foundExpression = true 296: continue 297: } 298: break 299: } 300: } catch (error) { 301: throw new Error( 302: `Failed to parse sed command: ${error instanceof Error ? error.message : 'Unknown error'}`, 303: ) 304: } 305: return expressions 306: } 307: function containsDangerousOperations(expression: string): boolean { 308: const cmd = expression.trim() 309: if (!cmd) return false 310: if (/[^\x01-\x7F]/.test(cmd)) { 311: return true 312: } 313: if (cmd.includes('{') || cmd.includes('}')) { 314: return true 315: } 316: if (cmd.includes('\n')) { 317: return true 318: } 319: const hashIndex = cmd.indexOf('#') 320: if (hashIndex !== -1 && !(hashIndex > 0 && cmd[hashIndex - 1] === 's')) { 321: return true 322: } 323: if (/^!/.test(cmd) || /[/\d$]!/.test(cmd)) { 324: return true 325: } 326: if (/\d\s*~\s*\d|,\s*~\s*\d|\$\s*~\s*\d/.test(cmd)) { 327: return true 328: } 329: if (/^,/.test(cmd)) { 330: return true 331: } 332: if (/,\s*[+-]/.test(cmd)) { 333: return true 334: } 335: if (/s\\/.test(cmd) || /\\[|#%@]/.test(cmd)) { 336: return true 337: } 338: if (/\\\/.*[wW]/.test(cmd)) { 339: return true 340: } 341: if (/\/[^/]*\s+[wWeE]/.test(cmd)) { 342: return true 343: } 344: if (/^s\//.test(cmd) && !/^s\/[^/]*\/[^/]*\/[^/]*$/.test(cmd)) { 345: return true 346: } 347: if (/^s./.test(cmd) && /[wWeE]$/.test(cmd)) { 348: const properSubst = /^s([^\\\n]).*?\1.*?\1[^wWeE]*$/.test(cmd) 349: if (!properSubst) { 350: return true 351: } 352: } 353: if ( 354: /^[wW]\s*\S+/.test(cmd) || 355: /^\d+\s*[wW]\s*\S+/.test(cmd) || 356: /^\$\s*[wW]\s*\S+/.test(cmd) || 357: /^\/[^/]*\/[IMim]*\s*[wW]\s*\S+/.test(cmd) || 358: /^\d+,\d+\s*[wW]\s*\S+/.test(cmd) || 359: /^\d+,\$\s*[wW]\s*\S+/.test(cmd) || 360: /^\/[^/]*\/[IMim]*,\/[^/]*\/[IMim]*\s*[wW]\s*\S+/.test(cmd) 361: ) { 362: return true 363: } 364: if ( 365: /^e/.test(cmd) || 366: /^\d+\s*e/.test(cmd) || 367: /^\$\s*e/.test(cmd) || 368: /^\/[^/]*\/[IMim]*\s*e/.test(cmd) || 369: /^\d+,\d+\s*e/.test(cmd) || 370: /^\d+,\$\s*e/.test(cmd) || 371: /^\/[^/]*\/[IMim]*,\/[^/]*\/[IMim]*\s*e/.test(cmd) 372: ) { 373: return true 374: } 375: const substitutionMatch = cmd.match(/s([^\\\n]).*?\1.*?\1(.*?)$/) 376: if (substitutionMatch) { 377: const flags = substitutionMatch[2] || '' 378: // Check for write flag: s/old/new/w filename or s/old/new/gw filename 379: if (flags.includes('w') || flags.includes('W')) { 380: return true 381: } 382: if (flags.includes('e') || flags.includes('E')) { 383: return true 384: } 385: } 386: const yCommandMatch = cmd.match(/y([^\\\n])/) 387: if (yCommandMatch) { 388: if (/[wWeE]/.test(cmd)) { 389: return true 390: } 391: } 392: return false 393: } 394: export function checkSedConstraints( 395: input: { command: string }, 396: toolPermissionContext: ToolPermissionContext, 397: ): PermissionResult { 398: const commands = splitCommand_DEPRECATED(input.command) 399: for (const cmd of commands) { 400: const trimmed = cmd.trim() 401: const baseCmd = trimmed.split(/\s+/)[0] 402: if (baseCmd !== 'sed') { 403: continue 404: } 405: const allowFileWrites = toolPermissionContext.mode === 'acceptEdits' 406: const isAllowed = sedCommandIsAllowedByAllowlist(trimmed, { 407: allowFileWrites, 408: }) 409: if (!isAllowed) { 410: return { 411: behavior: 'ask', 412: message: 413: 'sed command requires approval (contains potentially dangerous operations)', 414: decisionReason: { 415: type: 'other', 416: reason: 417: 'sed command contains operations that require explicit approval (e.g., write commands, execute commands)', 418: }, 419: } 420: } 421: } 422: return { 423: behavior: 'passthrough', 424: message: 'No dangerous sed operations detected', 425: } 426: }

File: src/tools/BashTool/shouldUseSandbox.ts

typescript 1: import { getFeatureValue_CACHED_MAY_BE_STALE } from 'src/services/analytics/growthbook.js' 2: import { splitCommand_DEPRECATED } from '../../utils/bash/commands.js' 3: import { SandboxManager } from '../../utils/sandbox/sandbox-adapter.js' 4: import { getSettings_DEPRECATED } from '../../utils/settings/settings.js' 5: import { 6: BINARY_HIJACK_VARS, 7: bashPermissionRule, 8: matchWildcardPattern, 9: stripAllLeadingEnvVars, 10: stripSafeWrappers, 11: } from './bashPermissions.js' 12: type SandboxInput = { 13: command?: string 14: dangerouslyDisableSandbox?: boolean 15: } 16: function containsExcludedCommand(command: string): boolean { 17: if (process.env.USER_TYPE === 'ant') { 18: const disabledCommands = getFeatureValue_CACHED_MAY_BE_STALE<{ 19: commands: string[] 20: substrings: string[] 21: }>('tengu_sandbox_disabled_commands', { commands: [], substrings: [] }) 22: for (const substring of disabledCommands.substrings) { 23: if (command.includes(substring)) { 24: return true 25: } 26: } 27: try { 28: const commandParts = splitCommand_DEPRECATED(command) 29: for (const part of commandParts) { 30: const baseCommand = part.trim().split(' ')[0] 31: if (baseCommand && disabledCommands.commands.includes(baseCommand)) { 32: return true 33: } 34: } 35: } catch { 36: } 37: } 38: const settings = getSettings_DEPRECATED() 39: const userExcludedCommands = settings.sandbox?.excludedCommands ?? [] 40: if (userExcludedCommands.length === 0) { 41: return false 42: } 43: let subcommands: string[] 44: try { 45: subcommands = splitCommand_DEPRECATED(command) 46: } catch { 47: subcommands = [command] 48: } 49: for (const subcommand of subcommands) { 50: const trimmed = subcommand.trim() 51: const candidates = [trimmed] 52: const seen = new Set(candidates) 53: let startIdx = 0 54: while (startIdx < candidates.length) { 55: const endIdx = candidates.length 56: for (let i = startIdx; i < endIdx; i++) { 57: const cmd = candidates[i]! 58: const envStripped = stripAllLeadingEnvVars(cmd, BINARY_HIJACK_VARS) 59: if (!seen.has(envStripped)) { 60: candidates.push(envStripped) 61: seen.add(envStripped) 62: } 63: const wrapperStripped = stripSafeWrappers(cmd) 64: if (!seen.has(wrapperStripped)) { 65: candidates.push(wrapperStripped) 66: seen.add(wrapperStripped) 67: } 68: } 69: startIdx = endIdx 70: } 71: for (const pattern of userExcludedCommands) { 72: const rule = bashPermissionRule(pattern) 73: for (const cand of candidates) { 74: switch (rule.type) { 75: case 'prefix': 76: if (cand === rule.prefix || cand.startsWith(rule.prefix + ' ')) { 77: return true 78: } 79: break 80: case 'exact': 81: if (cand === rule.command) { 82: return true 83: } 84: break 85: case 'wildcard': 86: if (matchWildcardPattern(rule.pattern, cand)) { 87: return true 88: } 89: break 90: } 91: } 92: } 93: } 94: return false 95: } 96: export function shouldUseSandbox(input: Partial<SandboxInput>): boolean { 97: if (!SandboxManager.isSandboxingEnabled()) { 98: return false 99: } 100: if ( 101: input.dangerouslyDisableSandbox && 102: SandboxManager.areUnsandboxedCommandsAllowed() 103: ) { 104: return false 105: } 106: if (!input.command) { 107: return false 108: } 109: if (containsExcludedCommand(input.command)) { 110: return false 111: } 112: return true 113: }

File: src/tools/BashTool/toolName.ts

typescript 1: export const BASH_TOOL_NAME = 'Bash'

File: src/tools/BashTool/UI.tsx

typescript 1: import { c as _c } from "react/compiler-runtime"; 2: import type { ToolResultBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'; 3: import * as React from 'react'; 4: import { KeyboardShortcutHint } from '../../components/design-system/KeyboardShortcutHint.js'; 5: import { FallbackToolUseErrorMessage } from '../../components/FallbackToolUseErrorMessage.js'; 6: import { MessageResponse } from '../../components/MessageResponse.js'; 7: import { ShellProgressMessage } from '../../components/shell/ShellProgressMessage.js'; 8: import { Box, Text } from '../../ink.js'; 9: import { useKeybinding } from '../../keybindings/useKeybinding.js'; 10: import { useShortcutDisplay } from '../../keybindings/useShortcutDisplay.js'; 11: import { useAppStateStore, useSetAppState } from '../../state/AppState.js'; 12: import type { Tool } from '../../Tool.js'; 13: import { backgroundAll } from '../../tasks/LocalShellTask/LocalShellTask.js'; 14: import type { ProgressMessage } from '../../types/message.js'; 15: import { env } from '../../utils/env.js'; 16: import { isEnvTruthy } from '../../utils/envUtils.js'; 17: import { getDisplayPath } from '../../utils/file.js'; 18: import { isFullscreenEnvEnabled } from '../../utils/fullscreen.js'; 19: import type { ThemeName } from '../../utils/theme.js'; 20: import type { BashProgress, BashToolInput, Out } from './BashTool.js'; 21: import BashToolResultMessage from './BashToolResultMessage.js'; 22: import { extractBashCommentLabel } from './commentLabel.js'; 23: import { parseSedEditCommand } from './sedEditParser.js'; 24: const MAX_COMMAND_DISPLAY_LINES = 2; 25: const MAX_COMMAND_DISPLAY_CHARS = 160; 26: export function BackgroundHint(t0) { 27: const $ = _c(9); 28: let t1; 29: if ($[0] !== t0) { 30: t1 = t0 === undefined ? {} : t0; 31: $[0] = t0; 32: $[1] = t1; 33: } else { 34: t1 = $[1]; 35: } 36: const { 37: onBackground 38: } = t1; 39: const store = useAppStateStore(); 40: const setAppState = useSetAppState(); 41: let t2; 42: if ($[2] !== onBackground || $[3] !== setAppState || $[4] !== store) { 43: t2 = () => { 44: backgroundAll(() => store.getState(), setAppState); 45: onBackground?.(); 46: }; 47: $[2] = onBackground; 48: $[3] = setAppState; 49: $[4] = store; 50: $[5] = t2; 51: } else { 52: t2 = $[5]; 53: } 54: const handleBackground = t2; 55: let t3; 56: if ($[6] === Symbol.for("react.memo_cache_sentinel")) { 57: t3 = { 58: context: "Task" 59: }; 60: $[6] = t3; 61: } else { 62: t3 = $[6]; 63: } 64: useKeybinding("task:background", handleBackground, t3); 65: const baseShortcut = useShortcutDisplay("task:background", "Task", "ctrl+b"); 66: const shortcut = env.terminal === "tmux" && baseShortcut === "ctrl+b" ? "ctrl+b ctrl+b (twice)" : baseShortcut; 67: if (isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_BACKGROUND_TASKS)) { 68: return null; 69: } 70: let t4; 71: if ($[7] !== shortcut) { 72: t4 = <Box paddingLeft={5}><Text dimColor={true}><KeyboardShortcutHint shortcut={shortcut} action="run in background" parens={true} /></Text></Box>; 73: $[7] = shortcut; 74: $[8] = t4; 75: } else { 76: t4 = $[8]; 77: } 78: return t4; 79: } 80: export function renderToolUseMessage(input: Partial<BashToolInput>, { 81: verbose, 82: theme: _theme 83: }: { 84: verbose: boolean; 85: theme: ThemeName; 86: }): React.ReactNode { 87: const { 88: command 89: } = input; 90: if (!command) { 91: return null; 92: } 93: const sedInfo = parseSedEditCommand(command); 94: if (sedInfo) { 95: return verbose ? sedInfo.filePath : getDisplayPath(sedInfo.filePath); 96: } 97: if (!verbose) { 98: const lines = command.split('\n'); 99: if (isFullscreenEnvEnabled()) { 100: const label = extractBashCommentLabel(command); 101: if (label) { 102: return label.length > MAX_COMMAND_DISPLAY_CHARS ? label.slice(0, MAX_COMMAND_DISPLAY_CHARS) + '…' : label; 103: } 104: } 105: const needsLineTruncation = lines.length > MAX_COMMAND_DISPLAY_LINES; 106: const needsCharTruncation = command.length > MAX_COMMAND_DISPLAY_CHARS; 107: if (needsLineTruncation || needsCharTruncation) { 108: let truncated = command; 109: if (needsLineTruncation) { 110: truncated = lines.slice(0, MAX_COMMAND_DISPLAY_LINES).join('\n'); 111: } 112: if (truncated.length > MAX_COMMAND_DISPLAY_CHARS) { 113: truncated = truncated.slice(0, MAX_COMMAND_DISPLAY_CHARS); 114: } 115: return <Text>{truncated.trim()}…</Text>; 116: } 117: } 118: return command; 119: } 120: export function renderToolUseProgressMessage(progressMessagesForMessage: ProgressMessage<BashProgress>[], { 121: verbose, 122: tools: _tools, 123: terminalSize: _terminalSize, 124: inProgressToolCallCount: _inProgressToolCallCount 125: }: { 126: tools: Tool[]; 127: verbose: boolean; 128: terminalSize?: { 129: columns: number; 130: rows: number; 131: }; 132: inProgressToolCallCount?: number; 133: }): React.ReactNode { 134: const lastProgress = progressMessagesForMessage.at(-1); 135: if (!lastProgress || !lastProgress.data) { 136: return <MessageResponse height={1}> 137: <Text dimColor>Running…</Text> 138: </MessageResponse>; 139: } 140: const data = lastProgress.data; 141: return <ShellProgressMessage fullOutput={data.fullOutput} output={data.output} elapsedTimeSeconds={data.elapsedTimeSeconds} totalLines={data.totalLines} totalBytes={data.totalBytes} timeoutMs={data.timeoutMs} taskId={data.taskId} verbose={verbose} />; 142: } 143: export function renderToolUseQueuedMessage(): React.ReactNode { 144: return <MessageResponse height={1}> 145: <Text dimColor>Waiting…</Text> 146: </MessageResponse>; 147: } 148: export function renderToolResultMessage(content: Out, progressMessagesForMessage: ProgressMessage<BashProgress>[], { 149: verbose, 150: theme: _theme, 151: tools: _tools, 152: style: _style 153: }: { 154: verbose: boolean; 155: theme: ThemeName; 156: tools: Tool[]; 157: style?: 'condensed'; 158: }): React.ReactNode { 159: const lastProgress = progressMessagesForMessage.at(-1); 160: const timeoutMs = lastProgress?.data?.timeoutMs; 161: return <BashToolResultMessage content={content} verbose={verbose} timeoutMs={timeoutMs} />; 162: } 163: export function renderToolUseErrorMessage(result: ToolResultBlockParam['content'], { 164: verbose, 165: progressMessagesForMessage: _progressMessagesForMessage, 166: tools: _tools 167: }: { 168: verbose: boolean; 169: progressMessagesForMessage: ProgressMessage<BashProgress>[]; 170: tools: Tool[]; 171: }): React.ReactNode { 172: return <FallbackToolUseErrorMessage result={result} verbose={verbose} />; 173: }

File: src/tools/BashTool/utils.ts

typescript 1: import type { 2: Base64ImageSource, 3: ContentBlockParam, 4: ToolResultBlockParam, 5: } from '@anthropic-ai/sdk/resources/index.mjs' 6: import { readFile, stat } from 'fs/promises' 7: import { getOriginalCwd } from 'src/bootstrap/state.js' 8: import { logEvent } from 'src/services/analytics/index.js' 9: import type { ToolPermissionContext } from 'src/Tool.js' 10: import { getCwd } from 'src/utils/cwd.js' 11: import { pathInAllowedWorkingPath } from 'src/utils/permissions/filesystem.js' 12: import { setCwd } from 'src/utils/Shell.js' 13: import { shouldMaintainProjectWorkingDir } from '../../utils/envUtils.js' 14: import { maybeResizeAndDownsampleImageBuffer } from '../../utils/imageResizer.js' 15: import { getMaxOutputLength } from '../../utils/shell/outputLimits.js' 16: import { countCharInString, plural } from '../../utils/stringUtils.js' 17: export function stripEmptyLines(content: string): string { 18: const lines = content.split('\n') 19: let startIndex = 0 20: while (startIndex < lines.length && lines[startIndex]?.trim() === '') { 21: startIndex++ 22: } 23: // Find the last non-empty line 24: let endIndex = lines.length - 1 25: while (endIndex >= 0 && lines[endIndex]?.trim() === '') { 26: endIndex-- 27: } 28: // If all lines are empty, return empty string 29: if (startIndex > endIndex) { 30: return '' 31: } 32: // Return the slice with non-empty lines 33: return lines.slice(startIndex, endIndex + 1).join('\n') 34: } 35: export function isImageOutput(content: string): boolean { 36: return /^data:image\/[a-z0-9.+_-]+;base64,/i.test(content) 37: } 38: const DATA_URI_RE = /^data:([^;]+);base64,(.+)$/ 39: export function parseDataUri( 40: s: string, 41: ): { mediaType: string; data: string } | null { 42: const match = s.trim().match(DATA_URI_RE) 43: if (!match || !match[1] || !match[2]) return null 44: return { mediaType: match[1], data: match[2] } 45: } 46: export function buildImageToolResult( 47: stdout: string, 48: toolUseID: string, 49: ): ToolResultBlockParam | null { 50: const parsed = parseDataUri(stdout) 51: if (!parsed) return null 52: return { 53: tool_use_id: toolUseID, 54: type: 'tool_result', 55: content: [ 56: { 57: type: 'image', 58: source: { 59: type: 'base64', 60: media_type: parsed.mediaType as Base64ImageSource['media_type'], 61: data: parsed.data, 62: }, 63: }, 64: ], 65: } 66: } 67: const MAX_IMAGE_FILE_SIZE = 20 * 1024 * 1024 68: export async function resizeShellImageOutput( 69: stdout: string, 70: outputFilePath: string | undefined, 71: outputFileSize: number | undefined, 72: ): Promise<string | null> { 73: let source = stdout 74: if (outputFilePath) { 75: const size = outputFileSize ?? (await stat(outputFilePath)).size 76: if (size > MAX_IMAGE_FILE_SIZE) return null 77: source = await readFile(outputFilePath, 'utf8') 78: } 79: const parsed = parseDataUri(source) 80: if (!parsed) return null 81: const buf = Buffer.from(parsed.data, 'base64') 82: const ext = parsed.mediaType.split('/')[1] || 'png' 83: const resized = await maybeResizeAndDownsampleImageBuffer( 84: buf, 85: buf.length, 86: ext, 87: ) 88: return `data:image/${resized.mediaType};base64,${resized.buffer.toString('base64')}` 89: } 90: export function formatOutput(content: string): { 91: totalLines: number 92: truncatedContent: string 93: isImage?: boolean 94: } { 95: const isImage = isImageOutput(content) 96: if (isImage) { 97: return { 98: totalLines: 1, 99: truncatedContent: content, 100: isImage, 101: } 102: } 103: const maxOutputLength = getMaxOutputLength() 104: if (content.length <= maxOutputLength) { 105: return { 106: totalLines: countCharInString(content, '\n') + 1, 107: truncatedContent: content, 108: isImage, 109: } 110: } 111: const truncatedPart = content.slice(0, maxOutputLength) 112: const remainingLines = countCharInString(content, '\n', maxOutputLength) + 1 113: const truncated = `${truncatedPart}\n\n... [${remainingLines} lines truncated] ...` 114: return { 115: totalLines: countCharInString(content, '\n') + 1, 116: truncatedContent: truncated, 117: isImage, 118: } 119: } 120: export const stdErrAppendShellResetMessage = (stderr: string): string => 121: `${stderr.trim()}\nShell cwd was reset to ${getOriginalCwd()}` 122: export function resetCwdIfOutsideProject( 123: toolPermissionContext: ToolPermissionContext, 124: ): boolean { 125: const cwd = getCwd() 126: const originalCwd = getOriginalCwd() 127: const shouldMaintain = shouldMaintainProjectWorkingDir() 128: if ( 129: shouldMaintain || 130: (cwd !== originalCwd && 131: !pathInAllowedWorkingPath(cwd, toolPermissionContext)) 132: ) { 133: setCwd(originalCwd) 134: if (!shouldMaintain) { 135: logEvent('tengu_bash_tool_reset_to_original_dir', {}) 136: return true 137: } 138: } 139: return false 140: } 141: export function createContentSummary(content: ContentBlockParam[]): string { 142: const parts: string[] = [] 143: let textCount = 0 144: let imageCount = 0 145: for (const block of content) { 146: if (block.type === 'image') { 147: imageCount++ 148: } else if (block.type === 'text' && 'text' in block) { 149: textCount++ 150: const preview = block.text.slice(0, 200) 151: parts.push(preview + (block.text.length > 200 ? '...' : '')) 152: } 153: } 154: const summary: string[] = [] 155: if (imageCount > 0) { 156: summary.push(`[${imageCount} ${plural(imageCount, 'image')}]`) 157: } 158: if (textCount > 0) { 159: summary.push(`[${textCount} text ${plural(textCount, 'block')}]`) 160: } 161: return `MCP Result: ${summary.join(', ')}${parts.length > 0 ? '\n\n' + parts.join('\n\n') : ''}` 162: }

File: src/tools/BriefTool/attachments.ts

typescript 1: import { feature } from 'bun:bundle' 2: import { stat } from 'fs/promises' 3: import type { ValidationResult } from '../../Tool.js' 4: import { getCwd } from '../../utils/cwd.js' 5: import { isEnvTruthy } from '../../utils/envUtils.js' 6: import { getErrnoCode } from '../../utils/errors.js' 7: import { IMAGE_EXTENSION_REGEX } from '../../utils/imagePaste.js' 8: import { expandPath } from '../../utils/path.js' 9: export type ResolvedAttachment = { 10: path: string 11: size: number 12: isImage: boolean 13: file_uuid?: string 14: } 15: export async function validateAttachmentPaths( 16: rawPaths: string[], 17: ): Promise<ValidationResult> { 18: const cwd = getCwd() 19: for (const rawPath of rawPaths) { 20: const fullPath = expandPath(rawPath) 21: try { 22: const stats = await stat(fullPath) 23: if (!stats.isFile()) { 24: return { 25: result: false, 26: message: `Attachment "${rawPath}" is not a regular file.`, 27: errorCode: 1, 28: } 29: } 30: } catch (e) { 31: const code = getErrnoCode(e) 32: if (code === 'ENOENT') { 33: return { 34: result: false, 35: message: `Attachment "${rawPath}" does not exist. Current working directory: ${cwd}.`, 36: errorCode: 1, 37: } 38: } 39: if (code === 'EACCES' || code === 'EPERM') { 40: return { 41: result: false, 42: message: `Attachment "${rawPath}" is not accessible (permission denied).`, 43: errorCode: 1, 44: } 45: } 46: throw e 47: } 48: } 49: return { result: true } 50: } 51: export async function resolveAttachments( 52: rawPaths: string[], 53: uploadCtx: { replBridgeEnabled: boolean; signal?: AbortSignal }, 54: ): Promise<ResolvedAttachment[]> { 55: const stated: ResolvedAttachment[] = [] 56: for (const rawPath of rawPaths) { 57: const fullPath = expandPath(rawPath) 58: const stats = await stat(fullPath) 59: stated.push({ 60: path: fullPath, 61: size: stats.size, 62: isImage: IMAGE_EXTENSION_REGEX.test(fullPath), 63: }) 64: } 65: if (feature('BRIDGE_MODE')) { 66: const shouldUpload = 67: uploadCtx.replBridgeEnabled || 68: isEnvTruthy(process.env.CLAUDE_CODE_BRIEF_UPLOAD) 69: const { uploadBriefAttachment } = await import('./upload.js') 70: const uuids = await Promise.all( 71: stated.map(a => 72: uploadBriefAttachment(a.path, a.size, { 73: replBridgeEnabled: shouldUpload, 74: signal: uploadCtx.signal, 75: }), 76: ), 77: ) 78: return stated.map((a, i) => 79: uuids[i] === undefined ? a : { ...a, file_uuid: uuids[i] }, 80: ) 81: } 82: return stated 83: }

File: src/tools/BriefTool/BriefTool.ts

typescript 1: import { feature } from 'bun:bundle' 2: import { z } from 'zod/v4' 3: import { getKairosActive, getUserMsgOptIn } from '../../bootstrap/state.js' 4: import { getFeatureValue_CACHED_WITH_REFRESH } from '../../services/analytics/growthbook.js' 5: import { logEvent } from '../../services/analytics/index.js' 6: import type { ValidationResult } from '../../Tool.js' 7: import { buildTool, type ToolDef } from '../../Tool.js' 8: import { isEnvTruthy } from '../../utils/envUtils.js' 9: import { lazySchema } from '../../utils/lazySchema.js' 10: import { plural } from '../../utils/stringUtils.js' 11: import { resolveAttachments, validateAttachmentPaths } from './attachments.js' 12: import { 13: BRIEF_TOOL_NAME, 14: BRIEF_TOOL_PROMPT, 15: DESCRIPTION, 16: LEGACY_BRIEF_TOOL_NAME, 17: } from './prompt.js' 18: import { renderToolResultMessage, renderToolUseMessage } from './UI.js' 19: const inputSchema = lazySchema(() => 20: z.strictObject({ 21: message: z 22: .string() 23: .describe('The message for the user. Supports markdown formatting.'), 24: attachments: z 25: .array(z.string()) 26: .optional() 27: .describe( 28: 'Optional file paths (absolute or relative to cwd) to attach. Use for photos, screenshots, diffs, logs, or any file the user should see alongside your message.', 29: ), 30: status: z 31: .enum(['normal', 'proactive']) 32: .describe( 33: "Use 'proactive' when you're surfacing something the user hasn't asked for and needs to see now — task completion while they're away, a blocker you hit, an unsolicited status update. Use 'normal' when replying to something the user just said.", 34: ), 35: }), 36: ) 37: type InputSchema = ReturnType<typeof inputSchema> 38: const outputSchema = lazySchema(() => 39: z.object({ 40: message: z.string().describe('The message'), 41: attachments: z 42: .array( 43: z.object({ 44: path: z.string(), 45: size: z.number(), 46: isImage: z.boolean(), 47: file_uuid: z.string().optional(), 48: }), 49: ) 50: .optional() 51: .describe('Resolved attachment metadata'), 52: sentAt: z 53: .string() 54: .optional() 55: .describe( 56: 'ISO timestamp captured at tool execution on the emitting process. Optional — resumed sessions replay pre-sentAt outputs verbatim.', 57: ), 58: }), 59: ) 60: type OutputSchema = ReturnType<typeof outputSchema> 61: export type Output = z.infer<OutputSchema> 62: const KAIROS_BRIEF_REFRESH_MS = 5 * 60 * 1000 63: export function isBriefEntitled(): boolean { 64: return feature('KAIROS') || feature('KAIROS_BRIEF') 65: ? getKairosActive() || 66: isEnvTruthy(process.env.CLAUDE_CODE_BRIEF) || 67: getFeatureValue_CACHED_WITH_REFRESH( 68: 'tengu_kairos_brief', 69: false, 70: KAIROS_BRIEF_REFRESH_MS, 71: ) 72: : false 73: } 74: export function isBriefEnabled(): boolean { 75: return feature('KAIROS') || feature('KAIROS_BRIEF') 76: ? (getKairosActive() || getUserMsgOptIn()) && isBriefEntitled() 77: : false 78: } 79: export const BriefTool = buildTool({ 80: name: BRIEF_TOOL_NAME, 81: aliases: [LEGACY_BRIEF_TOOL_NAME], 82: searchHint: 83: 'send a message to the user — your primary visible output channel', 84: maxResultSizeChars: 100_000, 85: userFacingName() { 86: return '' 87: }, 88: get inputSchema(): InputSchema { 89: return inputSchema() 90: }, 91: get outputSchema(): OutputSchema { 92: return outputSchema() 93: }, 94: isEnabled() { 95: return isBriefEnabled() 96: }, 97: isConcurrencySafe() { 98: return true 99: }, 100: isReadOnly() { 101: return true 102: }, 103: toAutoClassifierInput(input) { 104: return input.message 105: }, 106: async validateInput({ attachments }, _context): Promise<ValidationResult> { 107: if (!attachments || attachments.length === 0) { 108: return { result: true } 109: } 110: return validateAttachmentPaths(attachments) 111: }, 112: async description() { 113: return DESCRIPTION 114: }, 115: async prompt() { 116: return BRIEF_TOOL_PROMPT 117: }, 118: mapToolResultToToolResultBlockParam(output, toolUseID) { 119: const n = output.attachments?.length ?? 0 120: const suffix = n === 0 ? '' : ` (${n} ${plural(n, 'attachment')} included)` 121: return { 122: tool_use_id: toolUseID, 123: type: 'tool_result', 124: content: `Message delivered to user.${suffix}`, 125: } 126: }, 127: renderToolUseMessage, 128: renderToolResultMessage, 129: async call({ message, attachments, status }, context) { 130: const sentAt = new Date().toISOString() 131: logEvent('tengu_brief_send', { 132: proactive: status === 'proactive', 133: attachment_count: attachments?.length ?? 0, 134: }) 135: if (!attachments || attachments.length === 0) { 136: return { data: { message, sentAt } } 137: } 138: const appState = context.getAppState() 139: const resolved = await resolveAttachments(attachments, { 140: replBridgeEnabled: appState.replBridgeEnabled, 141: signal: context.abortController.signal, 142: }) 143: return { 144: data: { message, attachments: resolved, sentAt }, 145: } 146: }, 147: } satisfies ToolDef<InputSchema, Output>)

File: src/tools/BriefTool/prompt.ts

typescript 1: export const BRIEF_TOOL_NAME = 'SendUserMessage' 2: export const LEGACY_BRIEF_TOOL_NAME = 'Brief' 3: export const DESCRIPTION = 'Send a message to the user' 4: export const BRIEF_TOOL_PROMPT = `Send a message the user will read. Text outside this tool is visible in the detail view, but most won't open it — the answer lives here. 5: \`message\` supports markdown. \`attachments\` takes file paths (absolute or cwd-relative) for images, diffs, logs. 6: \`status\` labels intent: 'normal' when replying to what they just asked; 'proactive' when you're initiating — a scheduled task finished, a blocker surfaced during background work, you need input on something they haven't asked about. Set it honestly; downstream routing uses it.` 7: export const BRIEF_PROACTIVE_SECTION = `## Talking to the user 8: ${BRIEF_TOOL_NAME} is where your replies go. Text outside it is visible if the user expands the detail view, but most won't — assume unread. Anything you want them to actually see goes through ${BRIEF_TOOL_NAME}. The failure mode: the real answer lives in plain text while ${BRIEF_TOOL_NAME} just says "done!" — they see "done!" and miss everything. 9: So: every time the user says something, the reply they actually read comes through ${BRIEF_TOOL_NAME}. Even for "hi". Even for "thanks". 10: If you can answer right away, send the answer. If you need to go look — run a command, read files, check something — ack first in one line ("On it — checking the test output"), then work, then send the result. Without the ack they're staring at a spinner. 11: For longer work: ack → work → result. Between those, send a checkpoint when something useful happened — a decision you made, a surprise you hit, a phase boundary. Skip the filler ("running tests...") — a checkpoint earns its place by carrying information. 12: Keep messages tight — the decision, the file:line, the PR number. Second person always ("your config"), never third.`

File: src/tools/BriefTool/UI.tsx

typescript 1: import { c as _c } from "react/compiler-runtime"; 2: import figures from 'figures'; 3: import React from 'react'; 4: import { Markdown } from '../../components/Markdown.js'; 5: import { BLACK_CIRCLE } from '../../constants/figures.js'; 6: import { Box, Text } from '../../ink.js'; 7: import type { ProgressMessage } from '../../types/message.js'; 8: import { getDisplayPath } from '../../utils/file.js'; 9: import { formatFileSize } from '../../utils/format.js'; 10: import { formatBriefTimestamp } from '../../utils/formatBriefTimestamp.js'; 11: import type { Output } from './BriefTool.js'; 12: export function renderToolUseMessage(): React.ReactNode { 13: return ''; 14: } 15: export function renderToolResultMessage(output: Output, _progressMessages: ProgressMessage[], options?: { 16: isTranscriptMode?: boolean; 17: isBriefOnly?: boolean; 18: }): React.ReactNode { 19: const hasAttachments = (output.attachments?.length ?? 0) > 0; 20: if (!output.message && !hasAttachments) { 21: return null; 22: } 23: // In transcript mode (ctrl+o), model text is NOT filtered — keep the ⏺ so 24: // SendUserMessage is visually distinct from the surrounding text blocks. 25: if (options?.isTranscriptMode) { 26: return <Box flexDirection="row" marginTop={1}> 27: <Box minWidth={2}> 28: <Text color="text">{BLACK_CIRCLE}</Text> 29: </Box> 30: <Box flexDirection="column"> 31: {output.message ? <Markdown>{output.message}</Markdown> : null} 32: <AttachmentList attachments={output.attachments} /> 33: </Box> 34: </Box>; 35: } 36: // Brief-only (chat) view: "Claude" label + 2-col indent, matching the "You" 37: // label UserPromptMessage applies to user input (#20889). The "N in background" 38: // spinner status lives in BriefSpinner (Spinner.tsx) — stateless label here. 39: if (options?.isBriefOnly) { 40: const ts = output.sentAt ? formatBriefTimestamp(output.sentAt) : ''; 41: return <Box flexDirection="column" marginTop={1} paddingLeft={2}> 42: <Box flexDirection="row"> 43: <Text color="briefLabelClaude">Claude</Text> 44: {ts ? <Text dimColor> {ts}</Text> : null} 45: </Box> 46: <Box flexDirection="column"> 47: {output.message ? <Markdown>{output.message}</Markdown> : null} 48: <AttachmentList attachments={output.attachments} /> 49: </Box> 50: </Box>; 51: } 52: // Default view: dropTextInBriefTurns (Messages.tsx) hides the redundant 53: // assistant text that would otherwise precede this — SendUserMessage is the 54: // only text-like content in its turn. No gutter mark; read as plain text. 55: // userFacingName() returns '' so UserToolSuccessMessage drops its columns-5 56: // width constraint and AssistantToolUseMessage renders null (no tool chrome). 57: // Empty minWidth={2} box mirrors AssistantTextMessage's ⏺ gutter spacing. 58: return <Box flexDirection="row" marginTop={1}> 59: <Box minWidth={2} /> 60: <Box flexDirection="column"> 61: {output.message ? <Markdown>{output.message}</Markdown> : null} 62: <AttachmentList attachments={output.attachments} /> 63: </Box> 64: </Box>; 65: } 66: type AttachmentListProps = { 67: attachments: Output['attachments']; 68: }; 69: export function AttachmentList(t0) { 70: const $ = _c(4); 71: const { 72: attachments 73: } = t0; 74: if (!attachments || attachments.length === 0) { 75: return null; 76: } 77: let t1; 78: if ($[0] !== attachments) { 79: t1 = attachments.map(_temp); 80: $[0] = attachments; 81: $[1] = t1; 82: } else { 83: t1 = $[1]; 84: } 85: let t2; 86: if ($[2] !== t1) { 87: t2 = <Box flexDirection="column" marginTop={1}>{t1}</Box>; 88: $[2] = t1; 89: $[3] = t2; 90: } else { 91: t2 = $[3]; 92: } 93: return t2; 94: } 95: function _temp(att) { 96: return <Box key={att.path} flexDirection="row"><Text dimColor={true}>{figures.pointerSmall} {att.isImage ? "[image]" : "[file]"}{" "}</Text><Text>{getDisplayPath(att.path)}</Text><Text dimColor={true}> ({formatFileSize(att.size)})</Text></Box>; 97: }

File: src/tools/BriefTool/upload.ts

typescript 1: import { feature } from 'bun:bundle' 2: import axios from 'axios' 3: import { randomUUID } from 'crypto' 4: import { readFile } from 'fs/promises' 5: import { basename, extname } from 'path' 6: import { z } from 'zod/v4' 7: import { 8: getBridgeAccessToken, 9: getBridgeBaseUrlOverride, 10: } from '../../bridge/bridgeConfig.js' 11: import { getOauthConfig } from '../../constants/oauth.js' 12: import { logForDebugging } from '../../utils/debug.js' 13: import { lazySchema } from '../../utils/lazySchema.js' 14: import { jsonStringify } from '../../utils/slowOperations.js' 15: const MAX_UPLOAD_BYTES = 30 * 1024 * 1024 16: const UPLOAD_TIMEOUT_MS = 30_000 17: const MIME_BY_EXT: Record<string, string> = { 18: '.png': 'image/png', 19: '.jpg': 'image/jpeg', 20: '.jpeg': 'image/jpeg', 21: '.gif': 'image/gif', 22: '.webp': 'image/webp', 23: } 24: function guessMimeType(filename: string): string { 25: const ext = extname(filename).toLowerCase() 26: return MIME_BY_EXT[ext] ?? 'application/octet-stream' 27: } 28: function debug(msg: string): void { 29: logForDebugging(`[brief:upload] ${msg}`) 30: } 31: function getBridgeBaseUrl(): string { 32: return ( 33: getBridgeBaseUrlOverride() ?? 34: process.env.ANTHROPIC_BASE_URL ?? 35: getOauthConfig().BASE_API_URL 36: ) 37: } 38: const uploadResponseSchema = lazySchema(() => 39: z.object({ file_uuid: z.string() }), 40: ) 41: export type BriefUploadContext = { 42: replBridgeEnabled: boolean 43: signal?: AbortSignal 44: } 45: export async function uploadBriefAttachment( 46: fullPath: string, 47: size: number, 48: ctx: BriefUploadContext, 49: ): Promise<string | undefined> { 50: if (feature('BRIDGE_MODE')) { 51: if (!ctx.replBridgeEnabled) return undefined 52: if (size > MAX_UPLOAD_BYTES) { 53: debug(`skip ${fullPath}: ${size} bytes exceeds ${MAX_UPLOAD_BYTES} limit`) 54: return undefined 55: } 56: const token = getBridgeAccessToken() 57: if (!token) { 58: debug('skip: no oauth token') 59: return undefined 60: } 61: let content: Buffer 62: try { 63: content = await readFile(fullPath) 64: } catch (e) { 65: debug(`read failed for ${fullPath}: ${e}`) 66: return undefined 67: } 68: const baseUrl = getBridgeBaseUrl() 69: const url = `${baseUrl}/api/oauth/file_upload` 70: const filename = basename(fullPath) 71: const mimeType = guessMimeType(filename) 72: const boundary = `----FormBoundary${randomUUID()}` 73: const body = Buffer.concat([ 74: Buffer.from( 75: `--${boundary}\r\n` + 76: `Content-Disposition: form-data; name="file"; filename="${filename}"\r\n` + 77: `Content-Type: ${mimeType}\r\n\r\n`, 78: ), 79: content, 80: Buffer.from(`\r\n--${boundary}--\r\n`), 81: ]) 82: try { 83: const response = await axios.post(url, body, { 84: headers: { 85: Authorization: `Bearer ${token}`, 86: 'Content-Type': `multipart/form-data; boundary=${boundary}`, 87: 'Content-Length': body.length.toString(), 88: }, 89: timeout: UPLOAD_TIMEOUT_MS, 90: signal: ctx.signal, 91: validateStatus: () => true, 92: }) 93: if (response.status !== 201) { 94: debug( 95: `upload failed for ${fullPath}: status=${response.status} body=${jsonStringify(response.data).slice(0, 200)}`, 96: ) 97: return undefined 98: } 99: const parsed = uploadResponseSchema().safeParse(response.data) 100: if (!parsed.success) { 101: debug( 102: `unexpected response shape for ${fullPath}: ${parsed.error.message}`, 103: ) 104: return undefined 105: } 106: debug(`uploaded ${fullPath} → ${parsed.data.file_uuid} (${size} bytes)`) 107: return parsed.data.file_uuid 108: } catch (e) { 109: debug(`upload threw for ${fullPath}: ${e}`) 110: return undefined 111: } 112: } 113: return undefined 114: }

File: src/tools/ConfigTool/ConfigTool.ts

typescript 1: import { feature } from 'bun:bundle' 2: import { z } from 'zod/v4' 3: import { 4: type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 5: logEvent, 6: } from '../../services/analytics/index.js' 7: import { buildTool, type ToolDef } from '../../Tool.js' 8: import { 9: type GlobalConfig, 10: getGlobalConfig, 11: getRemoteControlAtStartup, 12: saveGlobalConfig, 13: } from '../../utils/config.js' 14: import { errorMessage } from '../../utils/errors.js' 15: import { lazySchema } from '../../utils/lazySchema.js' 16: import { logError } from '../../utils/log.js' 17: import { 18: getInitialSettings, 19: updateSettingsForSource, 20: } from '../../utils/settings/settings.js' 21: import { jsonStringify } from '../../utils/slowOperations.js' 22: import { CONFIG_TOOL_NAME } from './constants.js' 23: import { DESCRIPTION, generatePrompt } from './prompt.js' 24: import { 25: getConfig, 26: getOptionsForSetting, 27: getPath, 28: isSupported, 29: } from './supportedSettings.js' 30: import { 31: renderToolResultMessage, 32: renderToolUseMessage, 33: renderToolUseRejectedMessage, 34: } from './UI.js' 35: const inputSchema = lazySchema(() => 36: z.strictObject({ 37: setting: z 38: .string() 39: .describe( 40: 'The setting key (e.g., "theme", "model", "permissions.defaultMode")', 41: ), 42: value: z 43: .union([z.string(), z.boolean(), z.number()]) 44: .optional() 45: .describe('The new value. Omit to get current value.'), 46: }), 47: ) 48: type InputSchema = ReturnType<typeof inputSchema> 49: const outputSchema = lazySchema(() => 50: z.object({ 51: success: z.boolean(), 52: operation: z.enum(['get', 'set']).optional(), 53: setting: z.string().optional(), 54: value: z.unknown().optional(), 55: previousValue: z.unknown().optional(), 56: newValue: z.unknown().optional(), 57: error: z.string().optional(), 58: }), 59: ) 60: type OutputSchema = ReturnType<typeof outputSchema> 61: export type Input = z.infer<InputSchema> 62: export type Output = z.infer<OutputSchema> 63: export const ConfigTool = buildTool({ 64: name: CONFIG_TOOL_NAME, 65: searchHint: 'get or set Claude Code settings (theme, model)', 66: maxResultSizeChars: 100_000, 67: async description() { 68: return DESCRIPTION 69: }, 70: async prompt() { 71: return generatePrompt() 72: }, 73: get inputSchema(): InputSchema { 74: return inputSchema() 75: }, 76: get outputSchema(): OutputSchema { 77: return outputSchema() 78: }, 79: userFacingName() { 80: return 'Config' 81: }, 82: shouldDefer: true, 83: isConcurrencySafe() { 84: return true 85: }, 86: isReadOnly(input: Input) { 87: return input.value === undefined 88: }, 89: toAutoClassifierInput(input) { 90: return input.value === undefined 91: ? input.setting 92: : `${input.setting} = ${input.value}` 93: }, 94: async checkPermissions(input: Input) { 95: if (input.value === undefined) { 96: return { behavior: 'allow' as const, updatedInput: input } 97: } 98: return { 99: behavior: 'ask' as const, 100: message: `Set ${input.setting} to ${jsonStringify(input.value)}`, 101: } 102: }, 103: renderToolUseMessage, 104: renderToolResultMessage, 105: renderToolUseRejectedMessage, 106: async call({ setting, value }: Input, context): Promise<{ data: Output }> { 107: if (feature('VOICE_MODE') && setting === 'voiceEnabled') { 108: const { isVoiceGrowthBookEnabled } = await import( 109: '../../voice/voiceModeEnabled.js' 110: ) 111: if (!isVoiceGrowthBookEnabled()) { 112: return { 113: data: { success: false, error: `Unknown setting: "${setting}"` }, 114: } 115: } 116: } 117: if (!isSupported(setting)) { 118: return { 119: data: { success: false, error: `Unknown setting: "${setting}"` }, 120: } 121: } 122: const config = getConfig(setting)! 123: const path = getPath(setting) 124: if (value === undefined) { 125: const currentValue = getValue(config.source, path) 126: const displayValue = config.formatOnRead 127: ? config.formatOnRead(currentValue) 128: : currentValue 129: return { 130: data: { success: true, operation: 'get', setting, value: displayValue }, 131: } 132: } 133: if ( 134: setting === 'remoteControlAtStartup' && 135: typeof value === 'string' && 136: value.toLowerCase().trim() === 'default' 137: ) { 138: saveGlobalConfig(prev => { 139: if (prev.remoteControlAtStartup === undefined) return prev 140: const next = { ...prev } 141: delete next.remoteControlAtStartup 142: return next 143: }) 144: const resolved = getRemoteControlAtStartup() 145: context.setAppState(prev => { 146: if (prev.replBridgeEnabled === resolved && !prev.replBridgeOutboundOnly) 147: return prev 148: return { 149: ...prev, 150: replBridgeEnabled: resolved, 151: replBridgeOutboundOnly: false, 152: } 153: }) 154: return { 155: data: { 156: success: true, 157: operation: 'set', 158: setting, 159: value: resolved, 160: }, 161: } 162: } 163: let finalValue: unknown = value 164: if (config.type === 'boolean') { 165: if (typeof value === 'string') { 166: const lower = value.toLowerCase().trim() 167: if (lower === 'true') finalValue = true 168: else if (lower === 'false') finalValue = false 169: } 170: if (typeof finalValue !== 'boolean') { 171: return { 172: data: { 173: success: false, 174: operation: 'set', 175: setting, 176: error: `${setting} requires true or false.`, 177: }, 178: } 179: } 180: } 181: const options = getOptionsForSetting(setting) 182: if (options && !options.includes(String(finalValue))) { 183: return { 184: data: { 185: success: false, 186: operation: 'set', 187: setting, 188: error: `Invalid value "${value}". Options: ${options.join(', ')}`, 189: }, 190: } 191: } 192: if (config.validateOnWrite) { 193: const result = await config.validateOnWrite(finalValue) 194: if (!result.valid) { 195: return { 196: data: { 197: success: false, 198: operation: 'set', 199: setting, 200: error: result.error, 201: }, 202: } 203: } 204: } 205: if ( 206: feature('VOICE_MODE') && 207: setting === 'voiceEnabled' && 208: finalValue === true 209: ) { 210: const { isVoiceModeEnabled } = await import( 211: '../../voice/voiceModeEnabled.js' 212: ) 213: if (!isVoiceModeEnabled()) { 214: const { isAnthropicAuthEnabled } = await import('../../utils/auth.js') 215: return { 216: data: { 217: success: false, 218: error: !isAnthropicAuthEnabled() 219: ? 'Voice mode requires a Claude.ai account. Please run /login to sign in.' 220: : 'Voice mode is not available.', 221: }, 222: } 223: } 224: const { isVoiceStreamAvailable } = await import( 225: '../../services/voiceStreamSTT.js' 226: ) 227: const { 228: checkRecordingAvailability, 229: checkVoiceDependencies, 230: requestMicrophonePermission, 231: } = await import('../../services/voice.js') 232: const recording = await checkRecordingAvailability() 233: if (!recording.available) { 234: return { 235: data: { 236: success: false, 237: error: 238: recording.reason ?? 239: 'Voice mode is not available in this environment.', 240: }, 241: } 242: } 243: if (!isVoiceStreamAvailable()) { 244: return { 245: data: { 246: success: false, 247: error: 248: 'Voice mode requires a Claude.ai account. Please run /login to sign in.', 249: }, 250: } 251: } 252: const deps = await checkVoiceDependencies() 253: if (!deps.available) { 254: return { 255: data: { 256: success: false, 257: error: 258: 'No audio recording tool found.' + 259: (deps.installCommand ? ` Run: ${deps.installCommand}` : ''), 260: }, 261: } 262: } 263: if (!(await requestMicrophonePermission())) { 264: let guidance: string 265: if (process.platform === 'win32') { 266: guidance = 'Settings \u2192 Privacy \u2192 Microphone' 267: } else if (process.platform === 'linux') { 268: guidance = "your system's audio settings" 269: } else { 270: guidance = 271: 'System Settings \u2192 Privacy & Security \u2192 Microphone' 272: } 273: return { 274: data: { 275: success: false, 276: error: `Microphone access is denied. To enable it, go to ${guidance}, then try again.`, 277: }, 278: } 279: } 280: } 281: const previousValue = getValue(config.source, path) 282: try { 283: if (config.source === 'global') { 284: const key = path[0] 285: if (!key) { 286: return { 287: data: { 288: success: false, 289: operation: 'set', 290: setting, 291: error: 'Invalid setting path', 292: }, 293: } 294: } 295: saveGlobalConfig(prev => { 296: if (prev[key as keyof GlobalConfig] === finalValue) return prev 297: return { ...prev, [key]: finalValue } 298: }) 299: } else { 300: const update = buildNestedObject(path, finalValue) 301: const result = updateSettingsForSource('userSettings', update) 302: if (result.error) { 303: return { 304: data: { 305: success: false, 306: operation: 'set', 307: setting, 308: error: result.error.message, 309: }, 310: } 311: } 312: } 313: if (feature('VOICE_MODE') && setting === 'voiceEnabled') { 314: const { settingsChangeDetector } = await import( 315: '../../utils/settings/changeDetector.js' 316: ) 317: settingsChangeDetector.notifyChange('userSettings') 318: } 319: if (config.appStateKey) { 320: const appKey = config.appStateKey 321: context.setAppState(prev => { 322: if (prev[appKey] === finalValue) return prev 323: return { ...prev, [appKey]: finalValue } 324: }) 325: } 326: if (setting === 'remoteControlAtStartup') { 327: const resolved = getRemoteControlAtStartup() 328: context.setAppState(prev => { 329: if ( 330: prev.replBridgeEnabled === resolved && 331: !prev.replBridgeOutboundOnly 332: ) 333: return prev 334: return { 335: ...prev, 336: replBridgeEnabled: resolved, 337: replBridgeOutboundOnly: false, 338: } 339: }) 340: } 341: logEvent('tengu_config_tool_changed', { 342: setting: 343: setting as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 344: value: String( 345: finalValue, 346: ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 347: }) 348: return { 349: data: { 350: success: true, 351: operation: 'set', 352: setting, 353: previousValue, 354: newValue: finalValue, 355: }, 356: } 357: } catch (error) { 358: logError(error) 359: return { 360: data: { 361: success: false, 362: operation: 'set', 363: setting, 364: error: errorMessage(error), 365: }, 366: } 367: } 368: }, 369: mapToolResultToToolResultBlockParam(content: Output, toolUseID: string) { 370: if (content.success) { 371: if (content.operation === 'get') { 372: return { 373: tool_use_id: toolUseID, 374: type: 'tool_result' as const, 375: content: `${content.setting} = ${jsonStringify(content.value)}`, 376: } 377: } 378: return { 379: tool_use_id: toolUseID, 380: type: 'tool_result' as const, 381: content: `Set ${content.setting} to ${jsonStringify(content.newValue)}`, 382: } 383: } 384: return { 385: tool_use_id: toolUseID, 386: type: 'tool_result' as const, 387: content: `Error: ${content.error}`, 388: is_error: true, 389: } 390: }, 391: } satisfies ToolDef<InputSchema, Output>) 392: function getValue(source: 'global' | 'settings', path: string[]): unknown { 393: if (source === 'global') { 394: const config = getGlobalConfig() 395: const key = path[0] 396: if (!key) return undefined 397: return config[key as keyof GlobalConfig] 398: } 399: const settings = getInitialSettings() 400: let current: unknown = settings 401: for (const key of path) { 402: if (current && typeof current === 'object' && key in current) { 403: current = (current as Record<string, unknown>)[key] 404: } else { 405: return undefined 406: } 407: } 408: return current 409: } 410: function buildNestedObject( 411: path: string[], 412: value: unknown, 413: ): Record<string, unknown> { 414: if (path.length === 0) { 415: return {} 416: } 417: const key = path[0]! 418: if (path.length === 1) { 419: return { [key]: value } 420: } 421: return { [key]: buildNestedObject(path.slice(1), value) } 422: }

File: src/tools/ConfigTool/constants.ts

typescript 1: export const CONFIG_TOOL_NAME = 'Config'

File: src/tools/ConfigTool/prompt.ts

typescript 1: import { feature } from 'bun:bundle' 2: import { getModelOptions } from '../../utils/model/modelOptions.js' 3: import { isVoiceGrowthBookEnabled } from '../../voice/voiceModeEnabled.js' 4: import { 5: getOptionsForSetting, 6: SUPPORTED_SETTINGS, 7: } from './supportedSettings.js' 8: export const DESCRIPTION = 'Get or set Claude Code configuration settings.' 9: export function generatePrompt(): string { 10: const globalSettings: string[] = [] 11: const projectSettings: string[] = [] 12: for (const [key, config] of Object.entries(SUPPORTED_SETTINGS)) { 13: if (key === 'model') continue 14: if ( 15: feature('VOICE_MODE') && 16: key === 'voiceEnabled' && 17: !isVoiceGrowthBookEnabled() 18: ) 19: continue 20: const options = getOptionsForSetting(key) 21: let line = `- ${key}` 22: if (options) { 23: line += `: ${options.map(o => `"${o}"`).join(', ')}` 24: } else if (config.type === 'boolean') { 25: line += `: true/false` 26: } 27: line += ` - ${config.description}` 28: if (config.source === 'global') { 29: globalSettings.push(line) 30: } else { 31: projectSettings.push(line) 32: } 33: } 34: const modelSection = generateModelSection() 35: return `Get or set Claude Code configuration settings. 36: View or change Claude Code settings. Use when the user requests configuration changes, asks about current settings, or when adjusting a setting would benefit them. 37: ## Usage 38: - **Get current value:** Omit the "value" parameter 39: - **Set new value:** Include the "value" parameter 40: ## Configurable settings list 41: The following settings are available for you to change: 42: ### Global Settings (stored in ~/.claude.json) 43: ${globalSettings.join('\n')} 44: ### Project Settings (stored in settings.json) 45: ${projectSettings.join('\n')} 46: ${modelSection} 47: ## Examples 48: - Get theme: { "setting": "theme" } 49: - Set dark theme: { "setting": "theme", "value": "dark" } 50: - Enable vim mode: { "setting": "editorMode", "value": "vim" } 51: - Enable verbose: { "setting": "verbose", "value": true } 52: - Change model: { "setting": "model", "value": "opus" } 53: - Change permission mode: { "setting": "permissions.defaultMode", "value": "plan" } 54: ` 55: } 56: function generateModelSection(): string { 57: try { 58: const options = getModelOptions() 59: const lines = options.map(o => { 60: const value = o.value === null ? 'null/"default"' : `"${o.value}"` 61: return ` - ${value}: ${o.descriptionForModel ?? o.description}` 62: }) 63: return `## Model 64: - model - Override the default model. Available options: 65: ${lines.join('\n')}` 66: } catch { 67: return `## Model 68: - model - Override the default model (sonnet, opus, haiku, best, or full model ID)` 69: } 70: }

File: src/tools/ConfigTool/supportedSettings.ts

typescript 1: import { feature } from 'bun:bundle' 2: import { getRemoteControlAtStartup } from '../../utils/config.js' 3: import { 4: EDITOR_MODES, 5: NOTIFICATION_CHANNELS, 6: TEAMMATE_MODES, 7: } from '../../utils/configConstants.js' 8: import { getModelOptions } from '../../utils/model/modelOptions.js' 9: import { validateModel } from '../../utils/model/validateModel.js' 10: import { THEME_NAMES, THEME_SETTINGS } from '../../utils/theme.js' 11: type SyncableAppStateKey = 'verbose' | 'mainLoopModel' | 'thinkingEnabled' 12: type SettingConfig = { 13: source: 'global' | 'settings' 14: type: 'boolean' | 'string' 15: description: string 16: path?: string[] 17: options?: readonly string[] 18: getOptions?: () => string[] 19: appStateKey?: SyncableAppStateKey 20: validateOnWrite?: (v: unknown) => Promise<{ valid: boolean; error?: string }> 21: formatOnRead?: (v: unknown) => unknown 22: } 23: export const SUPPORTED_SETTINGS: Record<string, SettingConfig> = { 24: theme: { 25: source: 'global', 26: type: 'string', 27: description: 'Color theme for the UI', 28: options: feature('AUTO_THEME') ? THEME_SETTINGS : THEME_NAMES, 29: }, 30: editorMode: { 31: source: 'global', 32: type: 'string', 33: description: 'Key binding mode', 34: options: EDITOR_MODES, 35: }, 36: verbose: { 37: source: 'global', 38: type: 'boolean', 39: description: 'Show detailed debug output', 40: appStateKey: 'verbose', 41: }, 42: preferredNotifChannel: { 43: source: 'global', 44: type: 'string', 45: description: 'Preferred notification channel', 46: options: NOTIFICATION_CHANNELS, 47: }, 48: autoCompactEnabled: { 49: source: 'global', 50: type: 'boolean', 51: description: 'Auto-compact when context is full', 52: }, 53: autoMemoryEnabled: { 54: source: 'settings', 55: type: 'boolean', 56: description: 'Enable auto-memory', 57: }, 58: autoDreamEnabled: { 59: source: 'settings', 60: type: 'boolean', 61: description: 'Enable background memory consolidation', 62: }, 63: fileCheckpointingEnabled: { 64: source: 'global', 65: type: 'boolean', 66: description: 'Enable file checkpointing for code rewind', 67: }, 68: showTurnDuration: { 69: source: 'global', 70: type: 'boolean', 71: description: 72: 'Show turn duration message after responses (e.g., "Cooked for 1m 6s")', 73: }, 74: terminalProgressBarEnabled: { 75: source: 'global', 76: type: 'boolean', 77: description: 'Show OSC 9;4 progress indicator in supported terminals', 78: }, 79: todoFeatureEnabled: { 80: source: 'global', 81: type: 'boolean', 82: description: 'Enable todo/task tracking', 83: }, 84: model: { 85: source: 'settings', 86: type: 'string', 87: description: 'Override the default model', 88: appStateKey: 'mainLoopModel', 89: getOptions: () => { 90: try { 91: return getModelOptions() 92: .filter(o => o.value !== null) 93: .map(o => o.value as string) 94: } catch { 95: return ['sonnet', 'opus', 'haiku'] 96: } 97: }, 98: validateOnWrite: v => validateModel(String(v)), 99: formatOnRead: v => (v === null ? 'default' : v), 100: }, 101: alwaysThinkingEnabled: { 102: source: 'settings', 103: type: 'boolean', 104: description: 'Enable extended thinking (false to disable)', 105: appStateKey: 'thinkingEnabled', 106: }, 107: 'permissions.defaultMode': { 108: source: 'settings', 109: type: 'string', 110: description: 'Default permission mode for tool usage', 111: options: feature('TRANSCRIPT_CLASSIFIER') 112: ? ['default', 'plan', 'acceptEdits', 'dontAsk', 'auto'] 113: : ['default', 'plan', 'acceptEdits', 'dontAsk'], 114: }, 115: language: { 116: source: 'settings', 117: type: 'string', 118: description: 119: 'Preferred language for Claude responses and voice dictation (e.g., "japanese", "spanish")', 120: }, 121: teammateMode: { 122: source: 'global', 123: type: 'string', 124: description: 125: 'How to spawn teammates: "tmux" for traditional tmux, "in-process" for same process, "auto" to choose automatically', 126: options: TEAMMATE_MODES, 127: }, 128: ...(process.env.USER_TYPE === 'ant' 129: ? { 130: classifierPermissionsEnabled: { 131: source: 'settings' as const, 132: type: 'boolean' as const, 133: description: 134: 'Enable AI-based classification for Bash(prompt:...) permission rules', 135: }, 136: } 137: : {}), 138: ...(feature('VOICE_MODE') 139: ? { 140: voiceEnabled: { 141: source: 'settings' as const, 142: type: 'boolean' as const, 143: description: 'Enable voice dictation (hold-to-talk)', 144: }, 145: } 146: : {}), 147: ...(feature('BRIDGE_MODE') 148: ? { 149: remoteControlAtStartup: { 150: source: 'global' as const, 151: type: 'boolean' as const, 152: description: 153: 'Enable Remote Control for all sessions (true | false | default)', 154: formatOnRead: () => getRemoteControlAtStartup(), 155: }, 156: } 157: : {}), 158: ...(feature('KAIROS') || feature('KAIROS_PUSH_NOTIFICATION') 159: ? { 160: taskCompleteNotifEnabled: { 161: source: 'global' as const, 162: type: 'boolean' as const, 163: description: 164: 'Push to your mobile device when idle after Claude finishes (requires Remote Control)', 165: }, 166: inputNeededNotifEnabled: { 167: source: 'global' as const, 168: type: 'boolean' as const, 169: description: 170: 'Push to your mobile device when a permission prompt or question is waiting (requires Remote Control)', 171: }, 172: agentPushNotifEnabled: { 173: source: 'global' as const, 174: type: 'boolean' as const, 175: description: 176: 'Allow Claude to push to your mobile device when it deems it appropriate (requires Remote Control)', 177: }, 178: } 179: : {}), 180: } 181: export function isSupported(key: string): boolean { 182: return key in SUPPORTED_SETTINGS 183: } 184: export function getConfig(key: string): SettingConfig | undefined { 185: return SUPPORTED_SETTINGS[key] 186: } 187: export function getAllKeys(): string[] { 188: return Object.keys(SUPPORTED_SETTINGS) 189: } 190: export function getOptionsForSetting(key: string): string[] | undefined { 191: const config = SUPPORTED_SETTINGS[key] 192: if (!config) return undefined 193: if (config.options) return [...config.options] 194: if (config.getOptions) return config.getOptions() 195: return undefined 196: } 197: export function getPath(key: string): string[] { 198: const config = SUPPORTED_SETTINGS[key] 199: return config?.path ?? key.split('.') 200: }

File: src/tools/ConfigTool/UI.tsx

typescript 1: import React from 'react'; 2: import { MessageResponse } from '../../components/MessageResponse.js'; 3: import { Text } from '../../ink.js'; 4: import { jsonStringify } from '../../utils/slowOperations.js'; 5: import type { Input, Output } from './ConfigTool.js'; 6: export function renderToolUseMessage(input: Partial<Input>): React.ReactNode { 7: if (!input.setting) return null; 8: if (input.value === undefined) { 9: return <Text dimColor>Getting {input.setting}</Text>; 10: } 11: return <Text dimColor> 12: Setting {input.setting} to {jsonStringify(input.value)} 13: </Text>; 14: } 15: export function renderToolResultMessage(content: Output): React.ReactNode { 16: if (!content.success) { 17: return <MessageResponse> 18: <Text color="error">Failed: {content.error}</Text> 19: </MessageResponse>; 20: } 21: if (content.operation === 'get') { 22: return <MessageResponse> 23: <Text> 24: <Text bold>{content.setting}</Text> = {jsonStringify(content.value)} 25: </Text> 26: </MessageResponse>; 27: } 28: return <MessageResponse> 29: <Text> 30: Set <Text bold>{content.setting}</Text> to{' '} 31: <Text bold>{jsonStringify(content.newValue)}</Text> 32: </Text> 33: </MessageResponse>; 34: } 35: export function renderToolUseRejectedMessage(): React.ReactNode { 36: return <Text color="warning">Config change rejected</Text>; 37: }

File: src/tools/EnterPlanModeTool/constants.ts

typescript 1: export const ENTER_PLAN_MODE_TOOL_NAME = 'EnterPlanMode'

File: src/tools/EnterPlanModeTool/EnterPlanModeTool.ts

typescript 1: import { feature } from 'bun:bundle' 2: import { z } from 'zod/v4' 3: import { 4: getAllowedChannels, 5: handlePlanModeTransition, 6: } from '../../bootstrap/state.js' 7: import type { Tool } from '../../Tool.js' 8: import { buildTool, type ToolDef } from '../../Tool.js' 9: import { lazySchema } from '../../utils/lazySchema.js' 10: import { applyPermissionUpdate } from '../../utils/permissions/PermissionUpdate.js' 11: import { prepareContextForPlanMode } from '../../utils/permissions/permissionSetup.js' 12: import { isPlanModeInterviewPhaseEnabled } from '../../utils/planModeV2.js' 13: import { ENTER_PLAN_MODE_TOOL_NAME } from './constants.js' 14: import { getEnterPlanModeToolPrompt } from './prompt.js' 15: import { 16: renderToolResultMessage, 17: renderToolUseMessage, 18: renderToolUseRejectedMessage, 19: } from './UI.js' 20: const inputSchema = lazySchema(() => 21: z.strictObject({ 22: }), 23: ) 24: type InputSchema = ReturnType<typeof inputSchema> 25: const outputSchema = lazySchema(() => 26: z.object({ 27: message: z.string().describe('Confirmation that plan mode was entered'), 28: }), 29: ) 30: type OutputSchema = ReturnType<typeof outputSchema> 31: export type Output = z.infer<OutputSchema> 32: export const EnterPlanModeTool: Tool<InputSchema, Output> = buildTool({ 33: name: ENTER_PLAN_MODE_TOOL_NAME, 34: searchHint: 'switch to plan mode to design an approach before coding', 35: maxResultSizeChars: 100_000, 36: async description() { 37: return 'Requests permission to enter plan mode for complex tasks requiring exploration and design' 38: }, 39: async prompt() { 40: return getEnterPlanModeToolPrompt() 41: }, 42: get inputSchema(): InputSchema { 43: return inputSchema() 44: }, 45: get outputSchema(): OutputSchema { 46: return outputSchema() 47: }, 48: userFacingName() { 49: return '' 50: }, 51: shouldDefer: true, 52: isEnabled() { 53: // When --channels is active, ExitPlanMode is disabled (its approval 54: // dialog needs the terminal). Disable entry too so plan mode isn't a 55: if ( 56: (feature('KAIROS') || feature('KAIROS_CHANNELS')) && 57: getAllowedChannels().length > 0 58: ) { 59: return false 60: } 61: return true 62: }, 63: isConcurrencySafe() { 64: return true 65: }, 66: isReadOnly() { 67: return true 68: }, 69: renderToolUseMessage, 70: renderToolResultMessage, 71: renderToolUseRejectedMessage, 72: async call(_input, context) { 73: if (context.agentId) { 74: throw new Error('EnterPlanMode tool cannot be used in agent contexts') 75: } 76: const appState = context.getAppState() 77: handlePlanModeTransition(appState.toolPermissionContext.mode, 'plan') 78: context.setAppState(prev => ({ 79: ...prev, 80: toolPermissionContext: applyPermissionUpdate( 81: prepareContextForPlanMode(prev.toolPermissionContext), 82: { type: 'setMode', mode: 'plan', destination: 'session' }, 83: ), 84: })) 85: return { 86: data: { 87: message: 88: 'Entered plan mode. You should now focus on exploring the codebase and designing an implementation approach.', 89: }, 90: } 91: }, 92: mapToolResultToToolResultBlockParam({ message }, toolUseID) { 93: const instructions = isPlanModeInterviewPhaseEnabled() 94: ? `${message} 95: DO NOT write or edit any files except the plan file. Detailed workflow instructions will follow.` 96: : `${message} 97: In plan mode, you should: 98: 1. Thoroughly explore the codebase to understand existing patterns 99: 2. Identify similar features and architectural approaches 100: 3. Consider multiple approaches and their trade-offs 101: 4. Use AskUserQuestion if you need to clarify the approach 102: 5. Design a concrete implementation strategy 103: 6. When ready, use ExitPlanMode to present your plan for approval 104: Remember: DO NOT write or edit any files yet. This is a read-only exploration and planning phase.` 105: return { 106: type: 'tool_result', 107: content: instructions, 108: tool_use_id: toolUseID, 109: } 110: }, 111: } satisfies ToolDef<InputSchema, Output>)

File: src/tools/EnterPlanModeTool/prompt.ts

typescript 1: import { isPlanModeInterviewPhaseEnabled } from '../../utils/planModeV2.js' 2: import { ASK_USER_QUESTION_TOOL_NAME } from '../AskUserQuestionTool/prompt.js' 3: const WHAT_HAPPENS_SECTION = `## What Happens in Plan Mode 4: In plan mode, you'll: 5: 1. Thoroughly explore the codebase using Glob, Grep, and Read tools 6: 2. Understand existing patterns and architecture 7: 3. Design an implementation approach 8: 4. Present your plan to the user for approval 9: 5. Use ${ASK_USER_QUESTION_TOOL_NAME} if you need to clarify approaches 10: 6. Exit plan mode with ExitPlanMode when ready to implement 11: ` 12: function getEnterPlanModeToolPromptExternal(): string { 13: const whatHappens = isPlanModeInterviewPhaseEnabled() 14: ? '' 15: : WHAT_HAPPENS_SECTION 16: return `Use this tool proactively when you're about to start a non-trivial implementation task. Getting user sign-off on your approach before writing code prevents wasted effort and ensures alignment. This tool transitions you into plan mode where you can explore the codebase and design an implementation approach for user approval. 17: ## When to Use This Tool 18: **Prefer using EnterPlanMode** for implementation tasks unless they're simple. Use it when ANY of these conditions apply: 19: 1. **New Feature Implementation**: Adding meaningful new functionality 20: - Example: "Add a logout button" - where should it go? What should happen on click? 21: - Example: "Add form validation" - what rules? What error messages? 22: 2. **Multiple Valid Approaches**: The task can be solved in several different ways 23: - Example: "Add caching to the API" - could use Redis, in-memory, file-based, etc. 24: - Example: "Improve performance" - many optimization strategies possible 25: 3. **Code Modifications**: Changes that affect existing behavior or structure 26: - Example: "Update the login flow" - what exactly should change? 27: - Example: "Refactor this component" - what's the target architecture? 28: 4. **Architectural Decisions**: The task requires choosing between patterns or technologies 29: - Example: "Add real-time updates" - WebSockets vs SSE vs polling 30: - Example: "Implement state management" - Redux vs Context vs custom solution 31: 5. **Multi-File Changes**: The task will likely touch more than 2-3 files 32: - Example: "Refactor the authentication system" 33: - Example: "Add a new API endpoint with tests" 34: 6. **Unclear Requirements**: You need to explore before understanding the full scope 35: - Example: "Make the app faster" - need to profile and identify bottlenecks 36: - Example: "Fix the bug in checkout" - need to investigate root cause 37: 7. **User Preferences Matter**: The implementation could reasonably go multiple ways 38: - If you would use ${ASK_USER_QUESTION_TOOL_NAME} to clarify the approach, use EnterPlanMode instead 39: - Plan mode lets you explore first, then present options with context 40: ## When NOT to Use This Tool 41: Only skip EnterPlanMode for simple tasks: 42: - Single-line or few-line fixes (typos, obvious bugs, small tweaks) 43: - Adding a single function with clear requirements 44: - Tasks where the user has given very specific, detailed instructions 45: - Pure research/exploration tasks (use the Agent tool with explore agent instead) 46: ${whatHappens}## Examples 47: ### GOOD - Use EnterPlanMode: 48: User: "Add user authentication to the app" 49: - Requires architectural decisions (session vs JWT, where to store tokens, middleware structure) 50: User: "Optimize the database queries" 51: - Multiple approaches possible, need to profile first, significant impact 52: User: "Implement dark mode" 53: - Architectural decision on theme system, affects many components 54: User: "Add a delete button to the user profile" 55: - Seems simple but involves: where to place it, confirmation dialog, API call, error handling, state updates 56: User: "Update the error handling in the API" 57: - Affects multiple files, user should approve the approach 58: ### BAD - Don't use EnterPlanMode: 59: User: "Fix the typo in the README" 60: - Straightforward, no planning needed 61: User: "Add a console.log to debug this function" 62: - Simple, obvious implementation 63: User: "What files handle routing?" 64: - Research task, not implementation planning 65: ## Important Notes 66: - This tool REQUIRES user approval - they must consent to entering plan mode 67: - If unsure whether to use it, err on the side of planning - it's better to get alignment upfront than to redo work 68: - Users appreciate being consulted before significant changes are made to their codebase 69: ` 70: } 71: function getEnterPlanModeToolPromptAnt(): string { 72: // When interview phase is enabled, omit the "What Happens" section — 73: // detailed workflow instructions arrive via the plan_mode attachment (messages.ts). 74: const whatHappens = isPlanModeInterviewPhaseEnabled() 75: ? '' 76: : WHAT_HAPPENS_SECTION 77: return `Use this tool when a task has genuine ambiguity about the right approach and getting user input before coding would prevent significant rework. This tool transitions you into plan mode where you can explore the codebase and design an implementation approach for user approval. 78: ## When to Use This Tool 79: Plan mode is valuable when the implementation approach is genuinely unclear. Use it when: 80: 1. **Significant Architectural Ambiguity**: Multiple reasonable approaches exist and the choice meaningfully affects the codebase 81: - Example: "Add caching to the API" - Redis vs in-memory vs file-based 82: - Example: "Add real-time updates" - WebSockets vs SSE vs polling 83: 2. **Unclear Requirements**: You need to explore and clarify before you can make progress 84: - Example: "Make the app faster" - need to profile and identify bottlenecks 85: - Example: "Refactor this module" - need to understand what the target architecture should be 86: 3. **High-Impact Restructuring**: The task will significantly restructure existing code and getting buy-in first reduces risk 87: - Example: "Redesign the authentication system" 88: - Example: "Migrate from one state management approach to another" 89: ## When NOT to Use This Tool 90: Skip plan mode when you can reasonably infer the right approach: 91: - The task is straightforward even if it touches multiple files 92: - The user's request is specific enough that the implementation path is clear 93: - You're adding a feature with an obvious implementation pattern (e.g., adding a button, a new endpoint following existing conventions) 94: - Bug fixes where the fix is clear once you understand the bug 95: - Research/exploration tasks (use the Agent tool instead) 96: - The user says something like "can we work on X" or "let's do X" — just get started 97: When in doubt, prefer starting work and using ${ASK_USER_QUESTION_TOOL_NAME} for specific questions over entering a full planning phase. 98: ${whatHappens}## Examples 99: ### GOOD - Use EnterPlanMode: 100: User: "Add user authentication to the app" 101: - Genuinely ambiguous: session vs JWT, where to store tokens, middleware structure 102: User: "Redesign the data pipeline" 103: - Major restructuring where the wrong approach wastes significant effort 104: ### BAD - Don't use EnterPlanMode: 105: User: "Add a delete button to the user profile" 106: - Implementation path is clear; just do it 107: User: "Can we work on the search feature?" 108: - User wants to get started, not plan 109: User: "Update the error handling in the API" 110: - Start working; ask specific questions if needed 111: User: "Fix the typo in the README" 112: - Straightforward, no planning needed 113: ## Important Notes 114: - This tool REQUIRES user approval - they must consent to entering plan mode 115: ` 116: } 117: export function getEnterPlanModeToolPrompt(): string { 118: return process.env.USER_TYPE === 'ant' 119: ? getEnterPlanModeToolPromptAnt() 120: : getEnterPlanModeToolPromptExternal() 121: }

File: src/tools/EnterPlanModeTool/UI.tsx

typescript 1: import * as React from 'react'; 2: import { BLACK_CIRCLE } from 'src/constants/figures.js'; 3: import { getModeColor } from 'src/utils/permissions/PermissionMode.js'; 4: import { Box, Text } from '../../ink.js'; 5: import type { ToolProgressData } from '../../Tool.js'; 6: import type { ProgressMessage } from '../../types/message.js'; 7: import type { ThemeName } from '../../utils/theme.js'; 8: import type { Output } from './EnterPlanModeTool.js'; 9: export function renderToolUseMessage(): React.ReactNode { 10: return null; 11: } 12: export function renderToolResultMessage(_output: Output, _progressMessagesForMessage: ProgressMessage<ToolProgressData>[], _options: { 13: theme: ThemeName; 14: }): React.ReactNode { 15: return <Box flexDirection="column" marginTop={1}> 16: <Box flexDirection="row"> 17: <Text color={getModeColor('plan')}>{BLACK_CIRCLE}</Text> 18: <Text> Entered plan mode</Text> 19: </Box> 20: <Box paddingLeft={2}> 21: <Text dimColor> 22: Claude is now exploring and designing an implementation approach. 23: </Text> 24: </Box> 25: </Box>; 26: } 27: export function renderToolUseRejectedMessage(): React.ReactNode { 28: return <Box flexDirection="row" marginTop={1}> 29: <Text color={getModeColor('default')}>{BLACK_CIRCLE}</Text> 30: <Text> User declined to enter plan mode</Text> 31: </Box>; 32: }

File: src/tools/EnterWorktreeTool/constants.ts

typescript 1: export const ENTER_WORKTREE_TOOL_NAME = 'EnterWorktree'

File: src/tools/EnterWorktreeTool/EnterWorktreeTool.ts

typescript 1: import { z } from 'zod/v4' 2: import { getSessionId, setOriginalCwd } from '../../bootstrap/state.js' 3: import { clearSystemPromptSections } from '../../constants/systemPromptSections.js' 4: import { logEvent } from '../../services/analytics/index.js' 5: import type { Tool } from '../../Tool.js' 6: import { buildTool, type ToolDef } from '../../Tool.js' 7: import { clearMemoryFileCaches } from '../../utils/claudemd.js' 8: import { getCwd } from '../../utils/cwd.js' 9: import { findCanonicalGitRoot } from '../../utils/git.js' 10: import { lazySchema } from '../../utils/lazySchema.js' 11: import { getPlanSlug, getPlansDirectory } from '../../utils/plans.js' 12: import { setCwd } from '../../utils/Shell.js' 13: import { saveWorktreeState } from '../../utils/sessionStorage.js' 14: import { 15: createWorktreeForSession, 16: getCurrentWorktreeSession, 17: validateWorktreeSlug, 18: } from '../../utils/worktree.js' 19: import { ENTER_WORKTREE_TOOL_NAME } from './constants.js' 20: import { getEnterWorktreeToolPrompt } from './prompt.js' 21: import { renderToolResultMessage, renderToolUseMessage } from './UI.js' 22: const inputSchema = lazySchema(() => 23: z.strictObject({ 24: name: z 25: .string() 26: .superRefine((s, ctx) => { 27: try { 28: validateWorktreeSlug(s) 29: } catch (e) { 30: ctx.addIssue({ code: 'custom', message: (e as Error).message }) 31: } 32: }) 33: .optional() 34: .describe( 35: 'Optional name for the worktree. Each "/"-separated segment may contain only letters, digits, dots, underscores, and dashes; max 64 chars total. A random name is generated if not provided.', 36: ), 37: }), 38: ) 39: type InputSchema = ReturnType<typeof inputSchema> 40: const outputSchema = lazySchema(() => 41: z.object({ 42: worktreePath: z.string(), 43: worktreeBranch: z.string().optional(), 44: message: z.string(), 45: }), 46: ) 47: type OutputSchema = ReturnType<typeof outputSchema> 48: export type Output = z.infer<OutputSchema> 49: export const EnterWorktreeTool: Tool<InputSchema, Output> = buildTool({ 50: name: ENTER_WORKTREE_TOOL_NAME, 51: searchHint: 'create an isolated git worktree and switch into it', 52: maxResultSizeChars: 100_000, 53: async description() { 54: return 'Creates an isolated worktree (via git or configured hooks) and switches the session into it' 55: }, 56: async prompt() { 57: return getEnterWorktreeToolPrompt() 58: }, 59: get inputSchema(): InputSchema { 60: return inputSchema() 61: }, 62: get outputSchema(): OutputSchema { 63: return outputSchema() 64: }, 65: userFacingName() { 66: return 'Creating worktree' 67: }, 68: shouldDefer: true, 69: toAutoClassifierInput(input) { 70: return input.name ?? '' 71: }, 72: renderToolUseMessage, 73: renderToolResultMessage, 74: async call(input) { 75: // Validate not already in a worktree created by this session 76: if (getCurrentWorktreeSession()) { 77: throw new Error('Already in a worktree session') 78: } 79: const mainRepoRoot = findCanonicalGitRoot(getCwd()) 80: if (mainRepoRoot && mainRepoRoot !== getCwd()) { 81: process.chdir(mainRepoRoot) 82: setCwd(mainRepoRoot) 83: } 84: const slug = input.name ?? getPlanSlug() 85: const worktreeSession = await createWorktreeForSession(getSessionId(), slug) 86: process.chdir(worktreeSession.worktreePath) 87: setCwd(worktreeSession.worktreePath) 88: setOriginalCwd(getCwd()) 89: saveWorktreeState(worktreeSession) 90: clearSystemPromptSections() 91: clearMemoryFileCaches() 92: getPlansDirectory.cache.clear?.() 93: logEvent('tengu_worktree_created', { 94: mid_session: true, 95: }) 96: const branchInfo = worktreeSession.worktreeBranch 97: ? ` on branch ${worktreeSession.worktreeBranch}` 98: : '' 99: return { 100: data: { 101: worktreePath: worktreeSession.worktreePath, 102: worktreeBranch: worktreeSession.worktreeBranch, 103: message: `Created worktree at ${worktreeSession.worktreePath}${branchInfo}. The session is now working in the worktree. Use ExitWorktree to leave mid-session, or exit the session to be prompted.`, 104: }, 105: } 106: }, 107: mapToolResultToToolResultBlockParam({ message }, toolUseID) { 108: return { 109: type: 'tool_result', 110: content: message, 111: tool_use_id: toolUseID, 112: } 113: }, 114: } satisfies ToolDef<InputSchema, Output>)

File: src/tools/EnterWorktreeTool/prompt.ts

typescript 1: export function getEnterWorktreeToolPrompt(): string { 2: return `Use this tool ONLY when the user explicitly asks to work in a worktree. This tool creates an isolated git worktree and switches the current session into it. 3: ## When to Use 4: - The user explicitly says "worktree" (e.g., "start a worktree", "work in a worktree", "create a worktree", "use a worktree") 5: ## When NOT to Use 6: - The user asks to create a branch, switch branches, or work on a different branch — use git commands instead 7: - The user asks to fix a bug or work on a feature — use normal git workflow unless they specifically mention worktrees 8: - Never use this tool unless the user explicitly mentions "worktree" 9: ## Requirements 10: - Must be in a git repository, OR have WorktreeCreate/WorktreeRemove hooks configured in settings.json 11: - Must not already be in a worktree 12: ## Behavior 13: - In a git repository: creates a new git worktree inside \`.claude/worktrees/\` with a new branch based on HEAD 14: - Outside a git repository: delegates to WorktreeCreate/WorktreeRemove hooks for VCS-agnostic isolation 15: - Switches the session's working directory to the new worktree 16: - Use ExitWorktree to leave the worktree mid-session (keep or remove). On session exit, if still in the worktree, the user will be prompted to keep or remove it 17: ## Parameters 18: - \`name\` (optional): A name for the worktree. If not provided, a random name is generated. 19: ` 20: }

File: src/tools/EnterWorktreeTool/UI.tsx

typescript 1: import * as React from 'react'; 2: import { Box, Text } from '../../ink.js'; 3: import type { ToolProgressData } from '../../Tool.js'; 4: import type { ProgressMessage } from '../../types/message.js'; 5: import type { ThemeName } from '../../utils/theme.js'; 6: import type { Output } from './EnterWorktreeTool.js'; 7: export function renderToolUseMessage(): React.ReactNode { 8: return 'Creating worktree…'; 9: } 10: export function renderToolResultMessage(output: Output, _progressMessagesForMessage: ProgressMessage<ToolProgressData>[], _options: { 11: theme: ThemeName; 12: }): React.ReactNode { 13: return <Box flexDirection="column"> 14: <Text> 15: Switched to worktree on branch <Text bold>{output.worktreeBranch}</Text> 16: </Text> 17: <Text dimColor>{output.worktreePath}</Text> 18: </Box>; 19: }

File: src/tools/ExitPlanModeTool/constants.ts

typescript 1: export const EXIT_PLAN_MODE_TOOL_NAME = 'ExitPlanMode' 2: export const EXIT_PLAN_MODE_V2_TOOL_NAME = 'ExitPlanMode'

File: src/tools/ExitPlanModeTool/ExitPlanModeV2Tool.ts

typescript 1: import { feature } from 'bun:bundle' 2: import { writeFile } from 'fs/promises' 3: import { z } from 'zod/v4' 4: import { 5: getAllowedChannels, 6: hasExitedPlanModeInSession, 7: setHasExitedPlanMode, 8: setNeedsAutoModeExitAttachment, 9: setNeedsPlanModeExitAttachment, 10: } from '../../bootstrap/state.js' 11: import { logEvent } from '../../services/analytics/index.js' 12: import type { AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS } from '../../services/analytics/metadata.js' 13: import { 14: buildTool, 15: type Tool, 16: type ToolDef, 17: toolMatchesName, 18: } from '../../Tool.js' 19: import { formatAgentId, generateRequestId } from '../../utils/agentId.js' 20: import { isAgentSwarmsEnabled } from '../../utils/agentSwarmsEnabled.js' 21: import { logForDebugging } from '../../utils/debug.js' 22: import { 23: findInProcessTeammateTaskId, 24: setAwaitingPlanApproval, 25: } from '../../utils/inProcessTeammateHelpers.js' 26: import { lazySchema } from '../../utils/lazySchema.js' 27: import { logError } from '../../utils/log.js' 28: import { 29: getPlan, 30: getPlanFilePath, 31: persistFileSnapshotIfRemote, 32: } from '../../utils/plans.js' 33: import { jsonStringify } from '../../utils/slowOperations.js' 34: import { 35: getAgentName, 36: getTeamName, 37: isPlanModeRequired, 38: isTeammate, 39: } from '../../utils/teammate.js' 40: import { writeToMailbox } from '../../utils/teammateMailbox.js' 41: import { AGENT_TOOL_NAME } from '../AgentTool/constants.js' 42: import { TEAM_CREATE_TOOL_NAME } from '../TeamCreateTool/constants.js' 43: import { EXIT_PLAN_MODE_V2_TOOL_NAME } from './constants.js' 44: import { EXIT_PLAN_MODE_V2_TOOL_PROMPT } from './prompt.js' 45: import { 46: renderToolResultMessage, 47: renderToolUseMessage, 48: renderToolUseRejectedMessage, 49: } from './UI.js' 50: const autoModeStateModule = feature('TRANSCRIPT_CLASSIFIER') 51: ? (require('../../utils/permissions/autoModeState.js') as typeof import('../../utils/permissions/autoModeState.js')) 52: : null 53: const permissionSetupModule = feature('TRANSCRIPT_CLASSIFIER') 54: ? (require('../../utils/permissions/permissionSetup.js') as typeof import('../../utils/permissions/permissionSetup.js')) 55: : null 56: const allowedPromptSchema = lazySchema(() => 57: z.object({ 58: tool: z.enum(['Bash']).describe('The tool this prompt applies to'), 59: prompt: z 60: .string() 61: .describe( 62: 'Semantic description of the action, e.g. "run tests", "install dependencies"', 63: ), 64: }), 65: ) 66: export type AllowedPrompt = z.infer<ReturnType<typeof allowedPromptSchema>> 67: const inputSchema = lazySchema(() => 68: z 69: .strictObject({ 70: allowedPrompts: z 71: .array(allowedPromptSchema()) 72: .optional() 73: .describe( 74: 'Prompt-based permissions needed to implement the plan. These describe categories of actions rather than specific commands.', 75: ), 76: }) 77: .passthrough(), 78: ) 79: type InputSchema = ReturnType<typeof inputSchema> 80: export const _sdkInputSchema = lazySchema(() => 81: inputSchema().extend({ 82: plan: z 83: .string() 84: .optional() 85: .describe('The plan content (injected by normalizeToolInput from disk)'), 86: planFilePath: z 87: .string() 88: .optional() 89: .describe('The plan file path (injected by normalizeToolInput)'), 90: }), 91: ) 92: export const outputSchema = lazySchema(() => 93: z.object({ 94: plan: z 95: .string() 96: .nullable() 97: .describe('The plan that was presented to the user'), 98: isAgent: z.boolean(), 99: filePath: z 100: .string() 101: .optional() 102: .describe('The file path where the plan was saved'), 103: hasTaskTool: z 104: .boolean() 105: .optional() 106: .describe('Whether the Agent tool is available in the current context'), 107: planWasEdited: z 108: .boolean() 109: .optional() 110: .describe( 111: 'True when the user edited the plan (CCR web UI or Ctrl+G); determines whether the plan is echoed back in tool_result', 112: ), 113: awaitingLeaderApproval: z 114: .boolean() 115: .optional() 116: .describe( 117: 'When true, the teammate has sent a plan approval request to the team leader', 118: ), 119: requestId: z 120: .string() 121: .optional() 122: .describe('Unique identifier for the plan approval request'), 123: }), 124: ) 125: type OutputSchema = ReturnType<typeof outputSchema> 126: export type Output = z.infer<OutputSchema> 127: export const ExitPlanModeV2Tool: Tool<InputSchema, Output> = buildTool({ 128: name: EXIT_PLAN_MODE_V2_TOOL_NAME, 129: searchHint: 'present plan for approval and start coding (plan mode only)', 130: maxResultSizeChars: 100_000, 131: async description() { 132: return 'Prompts the user to exit plan mode and start coding' 133: }, 134: async prompt() { 135: return EXIT_PLAN_MODE_V2_TOOL_PROMPT 136: }, 137: get inputSchema(): InputSchema { 138: return inputSchema() 139: }, 140: get outputSchema(): OutputSchema { 141: return outputSchema() 142: }, 143: userFacingName() { 144: return '' 145: }, 146: shouldDefer: true, 147: isEnabled() { 148: // When --channels is active the user is likely on Telegram/Discord, not 149: // watching the TUI. The plan-approval dialog would hang. Paired with the 150: // same gate on EnterPlanMode so plan mode isn't a trap. 151: if ( 152: (feature('KAIROS') || feature('KAIROS_CHANNELS')) && 153: getAllowedChannels().length > 0 154: ) { 155: return false 156: } 157: return true 158: }, 159: isConcurrencySafe() { 160: return true 161: }, 162: isReadOnly() { 163: return false 164: }, 165: requiresUserInteraction() { 166: if (isTeammate()) { 167: return false 168: } 169: return true 170: }, 171: async validateInput(_input, { getAppState, options }) { 172: if (isTeammate()) { 173: return { result: true } 174: } 175: const mode = getAppState().toolPermissionContext.mode 176: if (mode !== 'plan') { 177: logEvent('tengu_exit_plan_mode_called_outside_plan', { 178: model: 179: options.mainLoopModel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 180: mode: mode as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 181: hasExitedPlanModeInSession: hasExitedPlanModeInSession(), 182: }) 183: return { 184: result: false, 185: message: 186: 'You are not in plan mode. This tool is only for exiting plan mode after writing a plan. If your plan was already approved, continue with implementation.', 187: errorCode: 1, 188: } 189: } 190: return { result: true } 191: }, 192: async checkPermissions(input, context) { 193: if (isTeammate()) { 194: return { 195: behavior: 'allow' as const, 196: updatedInput: input, 197: } 198: } 199: return { 200: behavior: 'ask' as const, 201: message: 'Exit plan mode?', 202: updatedInput: input, 203: } 204: }, 205: renderToolUseMessage, 206: renderToolResultMessage, 207: renderToolUseRejectedMessage, 208: async call(input, context) { 209: const isAgent = !!context.agentId 210: const filePath = getPlanFilePath(context.agentId) 211: const inputPlan = 212: 'plan' in input && typeof input.plan === 'string' ? input.plan : undefined 213: const plan = inputPlan ?? getPlan(context.agentId) 214: if (inputPlan !== undefined && filePath) { 215: await writeFile(filePath, inputPlan, 'utf-8').catch(e => logError(e)) 216: void persistFileSnapshotIfRemote() 217: } 218: if (isTeammate() && isPlanModeRequired()) { 219: if (!plan) { 220: throw new Error( 221: `No plan file found at ${filePath}. Please write your plan to this file before calling ExitPlanMode.`, 222: ) 223: } 224: const agentName = getAgentName() || 'unknown' 225: const teamName = getTeamName() 226: const requestId = generateRequestId( 227: 'plan_approval', 228: formatAgentId(agentName, teamName || 'default'), 229: ) 230: const approvalRequest = { 231: type: 'plan_approval_request', 232: from: agentName, 233: timestamp: new Date().toISOString(), 234: planFilePath: filePath, 235: planContent: plan, 236: requestId, 237: } 238: await writeToMailbox( 239: 'team-lead', 240: { 241: from: agentName, 242: text: jsonStringify(approvalRequest), 243: timestamp: new Date().toISOString(), 244: }, 245: teamName, 246: ) 247: const appState = context.getAppState() 248: const agentTaskId = findInProcessTeammateTaskId(agentName, appState) 249: if (agentTaskId) { 250: setAwaitingPlanApproval(agentTaskId, context.setAppState, true) 251: } 252: return { 253: data: { 254: plan, 255: isAgent: true, 256: filePath, 257: awaitingLeaderApproval: true, 258: requestId, 259: }, 260: } 261: } 262: const appState = context.getAppState() 263: let gateFallbackNotification: string | null = null 264: if (feature('TRANSCRIPT_CLASSIFIER')) { 265: const prePlanRaw = appState.toolPermissionContext.prePlanMode ?? 'default' 266: if ( 267: prePlanRaw === 'auto' && 268: !(permissionSetupModule?.isAutoModeGateEnabled() ?? false) 269: ) { 270: const reason = 271: permissionSetupModule?.getAutoModeUnavailableReason() ?? 272: 'circuit-breaker' 273: gateFallbackNotification = 274: permissionSetupModule?.getAutoModeUnavailableNotification(reason) ?? 275: 'auto mode unavailable' 276: logForDebugging( 277: `[auto-mode gate @ ExitPlanModeV2Tool] prePlanMode=${prePlanRaw} ` + 278: `but gate is off (reason=${reason}) — falling back to default on plan exit`, 279: { level: 'warn' }, 280: ) 281: } 282: } 283: if (gateFallbackNotification) { 284: context.addNotification?.({ 285: key: 'auto-mode-gate-plan-exit-fallback', 286: text: `plan exit → default · ${gateFallbackNotification}`, 287: priority: 'immediate', 288: color: 'warning', 289: timeoutMs: 10000, 290: }) 291: } 292: context.setAppState(prev => { 293: if (prev.toolPermissionContext.mode !== 'plan') return prev 294: setHasExitedPlanMode(true) 295: setNeedsPlanModeExitAttachment(true) 296: let restoreMode = prev.toolPermissionContext.prePlanMode ?? 'default' 297: if (feature('TRANSCRIPT_CLASSIFIER')) { 298: if ( 299: restoreMode === 'auto' && 300: !(permissionSetupModule?.isAutoModeGateEnabled() ?? false) 301: ) { 302: restoreMode = 'default' 303: } 304: const finalRestoringAuto = restoreMode === 'auto' 305: const autoWasUsedDuringPlan = 306: autoModeStateModule?.isAutoModeActive() ?? false 307: autoModeStateModule?.setAutoModeActive(finalRestoringAuto) 308: if (autoWasUsedDuringPlan && !finalRestoringAuto) { 309: setNeedsAutoModeExitAttachment(true) 310: } 311: } 312: const restoringToAuto = restoreMode === 'auto' 313: let baseContext = prev.toolPermissionContext 314: if (restoringToAuto) { 315: baseContext = 316: permissionSetupModule?.stripDangerousPermissionsForAutoMode( 317: baseContext, 318: ) ?? baseContext 319: } else if (prev.toolPermissionContext.strippedDangerousRules) { 320: baseContext = 321: permissionSetupModule?.restoreDangerousPermissions(baseContext) ?? 322: baseContext 323: } 324: return { 325: ...prev, 326: toolPermissionContext: { 327: ...baseContext, 328: mode: restoreMode, 329: prePlanMode: undefined, 330: }, 331: } 332: }) 333: const hasTaskTool = 334: isAgentSwarmsEnabled() && 335: context.options.tools.some(t => toolMatchesName(t, AGENT_TOOL_NAME)) 336: return { 337: data: { 338: plan, 339: isAgent, 340: filePath, 341: hasTaskTool: hasTaskTool || undefined, 342: planWasEdited: inputPlan !== undefined || undefined, 343: }, 344: } 345: }, 346: mapToolResultToToolResultBlockParam( 347: { 348: isAgent, 349: plan, 350: filePath, 351: hasTaskTool, 352: planWasEdited, 353: awaitingLeaderApproval, 354: requestId, 355: }, 356: toolUseID, 357: ) { 358: if (awaitingLeaderApproval) { 359: return { 360: type: 'tool_result', 361: content: `Your plan has been submitted to the team lead for approval. 362: Plan file: ${filePath} 363: **What happens next:** 364: 1. Wait for the team lead to review your plan 365: 2. You will receive a message in your inbox with approval/rejection 366: 3. If approved, you can proceed with implementation 367: 4. If rejected, refine your plan based on the feedback 368: **Important:** Do NOT proceed until you receive approval. Check your inbox for response. 369: Request ID: ${requestId}`, 370: tool_use_id: toolUseID, 371: } 372: } 373: if (isAgent) { 374: return { 375: type: 'tool_result', 376: content: 377: 'User has approved the plan. There is nothing else needed from you now. Please respond with "ok"', 378: tool_use_id: toolUseID, 379: } 380: } 381: if (!plan || plan.trim() === '') { 382: return { 383: type: 'tool_result', 384: content: 'User has approved exiting plan mode. You can now proceed.', 385: tool_use_id: toolUseID, 386: } 387: } 388: const teamHint = hasTaskTool 389: ? `\n\nIf this plan can be broken down into multiple independent tasks, consider using the ${TEAM_CREATE_TOOL_NAME} tool to create a team and parallelize the work.` 390: : '' 391: // Always include the plan — extractApprovedPlan() in the Ultraplan CCR 392: // flow parses the tool_result to retrieve the plan text for the local CLI. 393: // Label edited plans so the model knows the user changed something. 394: const planLabel = planWasEdited 395: ? 'Approved Plan (edited by user)' 396: : 'Approved Plan' 397: return { 398: type: 'tool_result', 399: content: `User has approved your plan. You can now start coding. Start with updating your todo list if applicable 400: Your plan has been saved to: ${filePath} 401: You can refer back to it if needed during implementation.${teamHint} 402: ## ${planLabel}: 403: ${plan}`, 404: tool_use_id: toolUseID, 405: } 406: }, 407: } satisfies ToolDef<InputSchema, Output>)

File: src/tools/ExitPlanModeTool/prompt.ts

typescript 1: const ASK_USER_QUESTION_TOOL_NAME = 'AskUserQuestion' 2: export const EXIT_PLAN_MODE_V2_TOOL_PROMPT = `Use this tool when you are in plan mode and have finished writing your plan to the plan file and are ready for user approval. 3: ## How This Tool Works 4: - You should have already written your plan to the plan file specified in the plan mode system message 5: - This tool does NOT take the plan content as a parameter - it will read the plan from the file you wrote 6: - This tool simply signals that you're done planning and ready for the user to review and approve 7: - The user will see the contents of your plan file when they review it 8: ## When to Use This Tool 9: IMPORTANT: Only use this tool when the task requires planning the implementation steps of a task that requires writing code. For research tasks where you're gathering information, searching files, reading files or in general trying to understand the codebase - do NOT use this tool. 10: ## Before Using This Tool 11: Ensure your plan is complete and unambiguous: 12: - If you have unresolved questions about requirements or approach, use ${ASK_USER_QUESTION_TOOL_NAME} first (in earlier phases) 13: - Once your plan is finalized, use THIS tool to request approval 14: **Important:** Do NOT use ${ASK_USER_QUESTION_TOOL_NAME} to ask "Is this plan okay?" or "Should I proceed?" - that's exactly what THIS tool does. ExitPlanMode inherently requests user approval of your plan. 15: ## Examples 16: 1. Initial task: "Search for and understand the implementation of vim mode in the codebase" - Do not use the exit plan mode tool because you are not planning the implementation steps of a task. 17: 2. Initial task: "Help me implement yank mode for vim" - Use the exit plan mode tool after you have finished planning the implementation steps of the task. 18: 3. Initial task: "Add a new feature to handle user authentication" - If unsure about auth method (OAuth, JWT, etc.), use ${ASK_USER_QUESTION_TOOL_NAME} first, then use exit plan mode tool after clarifying the approach. 19: `

File: src/tools/ExitPlanModeTool/UI.tsx

typescript 1: import * as React from 'react'; 2: import { Markdown } from 'src/components/Markdown.js'; 3: import { MessageResponse } from 'src/components/MessageResponse.js'; 4: import { RejectedPlanMessage } from 'src/components/messages/UserToolResultMessage/RejectedPlanMessage.js'; 5: import { BLACK_CIRCLE } from 'src/constants/figures.js'; 6: import { getModeColor } from 'src/utils/permissions/PermissionMode.js'; 7: import { Box, Text } from '../../ink.js'; 8: import type { ToolProgressData } from '../../Tool.js'; 9: import type { ProgressMessage } from '../../types/message.js'; 10: import { getDisplayPath } from '../../utils/file.js'; 11: import { getPlan } from '../../utils/plans.js'; 12: import type { ThemeName } from '../../utils/theme.js'; 13: import type { Output } from './ExitPlanModeV2Tool.js'; 14: export function renderToolUseMessage(): React.ReactNode { 15: return null; 16: } 17: export function renderToolResultMessage(output: Output, _progressMessagesForMessage: ProgressMessage<ToolProgressData>[], { 18: theme: _theme 19: }: { 20: theme: ThemeName; 21: }): React.ReactNode { 22: const { 23: plan, 24: filePath 25: } = output; 26: const isEmpty = !plan || plan.trim() === ''; 27: const displayPath = filePath ? getDisplayPath(filePath) : ''; 28: const awaitingLeaderApproval = output.awaitingLeaderApproval; 29: // Simplified message for empty plans 30: if (isEmpty) { 31: return <Box flexDirection="column" marginTop={1}> 32: <Box flexDirection="row"> 33: <Text color={getModeColor('plan')}>{BLACK_CIRCLE}</Text> 34: <Text> Exited plan mode</Text> 35: </Box> 36: </Box>; 37: } 38: if (awaitingLeaderApproval) { 39: return <Box flexDirection="column" marginTop={1}> 40: <Box flexDirection="row"> 41: <Text color={getModeColor('plan')}>{BLACK_CIRCLE}</Text> 42: <Text> Plan submitted for team lead approval</Text> 43: </Box> 44: <MessageResponse> 45: <Box flexDirection="column"> 46: {filePath && <Text dimColor>Plan file: {displayPath}</Text>} 47: <Text dimColor>Waiting for team lead to review and approve...</Text> 48: </Box> 49: </MessageResponse> 50: </Box>; 51: } 52: return <Box flexDirection="column" marginTop={1}> 53: <Box flexDirection="row"> 54: <Text color={getModeColor('plan')}>{BLACK_CIRCLE}</Text> 55: <Text> User approved Claude&apos;s plan</Text> 56: </Box> 57: <MessageResponse> 58: <Box flexDirection="column"> 59: {filePath && <Text dimColor>Plan saved to: {displayPath} · /plan to edit</Text>} 60: <Markdown>{plan}</Markdown> 61: </Box> 62: </MessageResponse> 63: </Box>; 64: } 65: export function renderToolUseRejectedMessage({ 66: plan 67: }: { 68: plan?: string; 69: }, { 70: theme: _theme 71: }: { 72: theme: ThemeName; 73: }): React.ReactNode { 74: const planContent = plan ?? getPlan() ?? 'No plan found'; 75: return <Box flexDirection="column"> 76: <RejectedPlanMessage plan={planContent} /> 77: </Box>; 78: }

File: src/tools/ExitWorktreeTool/constants.ts

typescript 1: export const EXIT_WORKTREE_TOOL_NAME = 'ExitWorktree'

File: src/tools/ExitWorktreeTool/ExitWorktreeTool.ts

typescript 1: import { z } from 'zod/v4' 2: import { 3: getOriginalCwd, 4: getProjectRoot, 5: setOriginalCwd, 6: setProjectRoot, 7: } from '../../bootstrap/state.js' 8: import { clearSystemPromptSections } from '../../constants/systemPromptSections.js' 9: import { logEvent } from '../../services/analytics/index.js' 10: import type { Tool } from '../../Tool.js' 11: import { buildTool, type ToolDef } from '../../Tool.js' 12: import { count } from '../../utils/array.js' 13: import { clearMemoryFileCaches } from '../../utils/claudemd.js' 14: import { execFileNoThrow } from '../../utils/execFileNoThrow.js' 15: import { updateHooksConfigSnapshot } from '../../utils/hooks/hooksConfigSnapshot.js' 16: import { lazySchema } from '../../utils/lazySchema.js' 17: import { getPlansDirectory } from '../../utils/plans.js' 18: import { setCwd } from '../../utils/Shell.js' 19: import { saveWorktreeState } from '../../utils/sessionStorage.js' 20: import { 21: cleanupWorktree, 22: getCurrentWorktreeSession, 23: keepWorktree, 24: killTmuxSession, 25: } from '../../utils/worktree.js' 26: import { EXIT_WORKTREE_TOOL_NAME } from './constants.js' 27: import { getExitWorktreeToolPrompt } from './prompt.js' 28: import { renderToolResultMessage, renderToolUseMessage } from './UI.js' 29: const inputSchema = lazySchema(() => 30: z.strictObject({ 31: action: z 32: .enum(['keep', 'remove']) 33: .describe( 34: '"keep" leaves the worktree and branch on disk; "remove" deletes both.', 35: ), 36: discard_changes: z 37: .boolean() 38: .optional() 39: .describe( 40: 'Required true when action is "remove" and the worktree has uncommitted files or unmerged commits. The tool will refuse and list them otherwise.', 41: ), 42: }), 43: ) 44: type InputSchema = ReturnType<typeof inputSchema> 45: const outputSchema = lazySchema(() => 46: z.object({ 47: action: z.enum(['keep', 'remove']), 48: originalCwd: z.string(), 49: worktreePath: z.string(), 50: worktreeBranch: z.string().optional(), 51: tmuxSessionName: z.string().optional(), 52: discardedFiles: z.number().optional(), 53: discardedCommits: z.number().optional(), 54: message: z.string(), 55: }), 56: ) 57: type OutputSchema = ReturnType<typeof outputSchema> 58: export type Output = z.infer<OutputSchema> 59: type ChangeSummary = { 60: changedFiles: number 61: commits: number 62: } 63: async function countWorktreeChanges( 64: worktreePath: string, 65: originalHeadCommit: string | undefined, 66: ): Promise<ChangeSummary | null> { 67: const status = await execFileNoThrow('git', [ 68: '-C', 69: worktreePath, 70: 'status', 71: '--porcelain', 72: ]) 73: if (status.code !== 0) { 74: return null 75: } 76: const changedFiles = count(status.stdout.split('\n'), l => l.trim() !== '') 77: if (!originalHeadCommit) { 78: // git status succeeded → this is a git repo, but without a baseline 79: // commit we cannot count commits. Fail-closed rather than claim 0. 80: return null 81: } 82: const revList = await execFileNoThrow('git', [ 83: '-C', 84: worktreePath, 85: 'rev-list', 86: '--count', 87: `${originalHeadCommit}..HEAD`, 88: ]) 89: if (revList.code !== 0) { 90: return null 91: } 92: const commits = parseInt(revList.stdout.trim(), 10) || 0 93: return { changedFiles, commits } 94: } 95: function restoreSessionToOriginalCwd( 96: originalCwd: string, 97: projectRootIsWorktree: boolean, 98: ): void { 99: setCwd(originalCwd) 100: setOriginalCwd(originalCwd) 101: if (projectRootIsWorktree) { 102: setProjectRoot(originalCwd) 103: updateHooksConfigSnapshot() 104: } 105: saveWorktreeState(null) 106: clearSystemPromptSections() 107: clearMemoryFileCaches() 108: getPlansDirectory.cache.clear?.() 109: } 110: export const ExitWorktreeTool: Tool<InputSchema, Output> = buildTool({ 111: name: EXIT_WORKTREE_TOOL_NAME, 112: searchHint: 'exit a worktree session and return to the original directory', 113: maxResultSizeChars: 100_000, 114: async description() { 115: return 'Exits a worktree session created by EnterWorktree and restores the original working directory' 116: }, 117: async prompt() { 118: return getExitWorktreeToolPrompt() 119: }, 120: get inputSchema(): InputSchema { 121: return inputSchema() 122: }, 123: get outputSchema(): OutputSchema { 124: return outputSchema() 125: }, 126: userFacingName() { 127: return 'Exiting worktree' 128: }, 129: shouldDefer: true, 130: isDestructive(input) { 131: return input.action === 'remove' 132: }, 133: toAutoClassifierInput(input) { 134: return input.action 135: }, 136: async validateInput(input) { 137: const session = getCurrentWorktreeSession() 138: if (!session) { 139: return { 140: result: false, 141: message: 142: 'No-op: there is no active EnterWorktree session to exit. This tool only operates on worktrees created by EnterWorktree in the current session — it will not touch worktrees created manually or in a previous session. No filesystem changes were made.', 143: errorCode: 1, 144: } 145: } 146: if (input.action === 'remove' && !input.discard_changes) { 147: const summary = await countWorktreeChanges( 148: session.worktreePath, 149: session.originalHeadCommit, 150: ) 151: if (summary === null) { 152: return { 153: result: false, 154: message: `Could not verify worktree state at ${session.worktreePath}. Refusing to remove without explicit confirmation. Re-invoke with discard_changes: true to proceed — or use action: "keep" to preserve the worktree.`, 155: errorCode: 3, 156: } 157: } 158: const { changedFiles, commits } = summary 159: if (changedFiles > 0 || commits > 0) { 160: const parts: string[] = [] 161: if (changedFiles > 0) { 162: parts.push( 163: `${changedFiles} uncommitted ${changedFiles === 1 ? 'file' : 'files'}`, 164: ) 165: } 166: if (commits > 0) { 167: parts.push( 168: `${commits} ${commits === 1 ? 'commit' : 'commits'} on ${session.worktreeBranch ?? 'the worktree branch'}`, 169: ) 170: } 171: return { 172: result: false, 173: message: `Worktree has ${parts.join(' and ')}. Removing will discard this work permanently. Confirm with the user, then re-invoke with discard_changes: true — or use action: "keep" to preserve the worktree.`, 174: errorCode: 2, 175: } 176: } 177: } 178: return { result: true } 179: }, 180: renderToolUseMessage, 181: renderToolResultMessage, 182: async call(input) { 183: const session = getCurrentWorktreeSession() 184: if (!session) { 185: throw new Error('Not in a worktree session') 186: } 187: const { 188: originalCwd, 189: worktreePath, 190: worktreeBranch, 191: tmuxSessionName, 192: originalHeadCommit, 193: } = session 194: const projectRootIsWorktree = getProjectRoot() === getOriginalCwd() 195: const { changedFiles, commits } = (await countWorktreeChanges( 196: worktreePath, 197: originalHeadCommit, 198: )) ?? { changedFiles: 0, commits: 0 } 199: if (input.action === 'keep') { 200: await keepWorktree() 201: restoreSessionToOriginalCwd(originalCwd, projectRootIsWorktree) 202: logEvent('tengu_worktree_kept', { 203: mid_session: true, 204: commits, 205: changed_files: changedFiles, 206: }) 207: const tmuxNote = tmuxSessionName 208: ? ` Tmux session ${tmuxSessionName} is still running; reattach with: tmux attach -t ${tmuxSessionName}` 209: : '' 210: return { 211: data: { 212: action: 'keep' as const, 213: originalCwd, 214: worktreePath, 215: worktreeBranch, 216: tmuxSessionName, 217: message: `Exited worktree. Your work is preserved at ${worktreePath}${worktreeBranch ? ` on branch ${worktreeBranch}` : ''}. Session is now back in ${originalCwd}.${tmuxNote}`, 218: }, 219: } 220: } 221: if (tmuxSessionName) { 222: await killTmuxSession(tmuxSessionName) 223: } 224: await cleanupWorktree() 225: restoreSessionToOriginalCwd(originalCwd, projectRootIsWorktree) 226: logEvent('tengu_worktree_removed', { 227: mid_session: true, 228: commits, 229: changed_files: changedFiles, 230: }) 231: const discardParts: string[] = [] 232: if (commits > 0) { 233: discardParts.push(`${commits} ${commits === 1 ? 'commit' : 'commits'}`) 234: } 235: if (changedFiles > 0) { 236: discardParts.push( 237: `${changedFiles} uncommitted ${changedFiles === 1 ? 'file' : 'files'}`, 238: ) 239: } 240: const discardNote = 241: discardParts.length > 0 ? ` Discarded ${discardParts.join(' and ')}.` : '' 242: return { 243: data: { 244: action: 'remove' as const, 245: originalCwd, 246: worktreePath, 247: worktreeBranch, 248: discardedFiles: changedFiles, 249: discardedCommits: commits, 250: message: `Exited and removed worktree at ${worktreePath}.${discardNote} Session is now back in ${originalCwd}.`, 251: }, 252: } 253: }, 254: mapToolResultToToolResultBlockParam({ message }, toolUseID) { 255: return { 256: type: 'tool_result', 257: content: message, 258: tool_use_id: toolUseID, 259: } 260: }, 261: } satisfies ToolDef<InputSchema, Output>)

File: src/tools/ExitWorktreeTool/prompt.ts

typescript 1: export function getExitWorktreeToolPrompt(): string { 2: return `Exit a worktree session created by EnterWorktree and return the session to the original working directory. 3: ## Scope 4: This tool ONLY operates on worktrees created by EnterWorktree in this session. It will NOT touch: 5: - Worktrees you created manually with \`git worktree add\` 6: - Worktrees from a previous session (even if created by EnterWorktree then) 7: - The directory you're in if EnterWorktree was never called 8: If called outside an EnterWorktree session, the tool is a **no-op**: it reports that no worktree session is active and takes no action. Filesystem state is unchanged. 9: ## When to Use 10: - The user explicitly asks to "exit the worktree", "leave the worktree", "go back", or otherwise end the worktree session 11: - Do NOT call this proactively — only when the user asks 12: ## Parameters 13: - \`action\` (required): \`"keep"\` or \`"remove"\` 14: - \`"keep"\` — leave the worktree directory and branch intact on disk. Use this if the user wants to come back to the work later, or if there are changes to preserve. 15: - \`"remove"\` — delete the worktree directory and its branch. Use this for a clean exit when the work is done or abandoned. 16: - \`discard_changes\` (optional, default false): only meaningful with \`action: "remove"\`. If the worktree has uncommitted files or commits not on the original branch, the tool will REFUSE to remove it unless this is set to \`true\`. If the tool returns an error listing changes, confirm with the user before re-invoking with \`discard_changes: true\`. 17: ## Behavior 18: - Restores the session's working directory to where it was before EnterWorktree 19: - Clears CWD-dependent caches (system prompt sections, memory files, plans directory) so the session state reflects the original directory 20: - If a tmux session was attached to the worktree: killed on \`remove\`, left running on \`keep\` (its name is returned so the user can reattach) 21: - Once exited, EnterWorktree can be called again to create a fresh worktree 22: ` 23: }

File: src/tools/ExitWorktreeTool/UI.tsx

typescript 1: import * as React from 'react'; 2: import { Box, Text } from '../../ink.js'; 3: import type { ToolProgressData } from '../../Tool.js'; 4: import type { ProgressMessage } from '../../types/message.js'; 5: import type { ThemeName } from '../../utils/theme.js'; 6: import type { Output } from './ExitWorktreeTool.js'; 7: export function renderToolUseMessage(): React.ReactNode { 8: return 'Exiting worktree…'; 9: } 10: export function renderToolResultMessage(output: Output, _progressMessagesForMessage: ProgressMessage<ToolProgressData>[], _options: { 11: theme: ThemeName; 12: }): React.ReactNode { 13: const actionLabel = output.action === 'keep' ? 'Kept worktree' : 'Removed worktree'; 14: return <Box flexDirection="column"> 15: <Text> 16: {actionLabel} 17: {output.worktreeBranch ? <> 18: {' '} 19: (branch <Text bold>{output.worktreeBranch}</Text>) 20: </> : null} 21: </Text> 22: <Text dimColor>Returned to {output.originalCwd}</Text> 23: </Box>; 24: }

File: src/tools/FileEditTool/constants.ts

typescript 1: export const FILE_EDIT_TOOL_NAME = 'Edit' 2: export const CLAUDE_FOLDER_PERMISSION_PATTERN = '/.claude/**' 3: export const GLOBAL_CLAUDE_FOLDER_PERMISSION_PATTERN = '~/.claude/**' 4: export const FILE_UNEXPECTEDLY_MODIFIED_ERROR = 5: 'File has been unexpectedly modified. Read it again before attempting to write it.'

File: src/tools/FileEditTool/FileEditTool.ts

typescript 1: import { dirname, isAbsolute, sep } from 'path' 2: import { logEvent } from 'src/services/analytics/index.js' 3: import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js' 4: import { diagnosticTracker } from '../../services/diagnosticTracking.js' 5: import { clearDeliveredDiagnosticsForFile } from '../../services/lsp/LSPDiagnosticRegistry.js' 6: import { getLspServerManager } from '../../services/lsp/manager.js' 7: import { notifyVscodeFileUpdated } from '../../services/mcp/vscodeSdkMcp.js' 8: import { checkTeamMemSecrets } from '../../services/teamMemorySync/teamMemSecretGuard.js' 9: import { 10: activateConditionalSkillsForPaths, 11: addSkillDirectories, 12: discoverSkillDirsForPaths, 13: } from '../../skills/loadSkillsDir.js' 14: import type { ToolUseContext } from '../../Tool.js' 15: import { buildTool, type ToolDef } from '../../Tool.js' 16: import { getCwd } from '../../utils/cwd.js' 17: import { logForDebugging } from '../../utils/debug.js' 18: import { countLinesChanged } from '../../utils/diff.js' 19: import { isEnvTruthy } from '../../utils/envUtils.js' 20: import { isENOENT } from '../../utils/errors.js' 21: import { 22: FILE_NOT_FOUND_CWD_NOTE, 23: findSimilarFile, 24: getFileModificationTime, 25: suggestPathUnderCwd, 26: writeTextContent, 27: } from '../../utils/file.js' 28: import { 29: fileHistoryEnabled, 30: fileHistoryTrackEdit, 31: } from '../../utils/fileHistory.js' 32: import { logFileOperation } from '../../utils/fileOperationAnalytics.js' 33: import { 34: type LineEndingType, 35: readFileSyncWithMetadata, 36: } from '../../utils/fileRead.js' 37: import { formatFileSize } from '../../utils/format.js' 38: import { getFsImplementation } from '../../utils/fsOperations.js' 39: import { 40: fetchSingleFileGitDiff, 41: type ToolUseDiff, 42: } from '../../utils/gitDiff.js' 43: import { logError } from '../../utils/log.js' 44: import { expandPath } from '../../utils/path.js' 45: import { 46: checkWritePermissionForTool, 47: matchingRuleForInput, 48: } from '../../utils/permissions/filesystem.js' 49: import type { PermissionDecision } from '../../utils/permissions/PermissionResult.js' 50: import { matchWildcardPattern } from '../../utils/permissions/shellRuleMatching.js' 51: import { validateInputForSettingsFileEdit } from '../../utils/settings/validateEditTool.js' 52: import { NOTEBOOK_EDIT_TOOL_NAME } from '../NotebookEditTool/constants.js' 53: import { 54: FILE_EDIT_TOOL_NAME, 55: FILE_UNEXPECTEDLY_MODIFIED_ERROR, 56: } from './constants.js' 57: import { getEditToolDescription } from './prompt.js' 58: import { 59: type FileEditInput, 60: type FileEditOutput, 61: inputSchema, 62: outputSchema, 63: } from './types.js' 64: import { 65: getToolUseSummary, 66: renderToolResultMessage, 67: renderToolUseErrorMessage, 68: renderToolUseMessage, 69: renderToolUseRejectedMessage, 70: userFacingName, 71: } from './UI.js' 72: import { 73: areFileEditsInputsEquivalent, 74: findActualString, 75: getPatchForEdit, 76: preserveQuoteStyle, 77: } from './utils.js' 78: const MAX_EDIT_FILE_SIZE = 1024 * 1024 * 1024 79: export const FileEditTool = buildTool({ 80: name: FILE_EDIT_TOOL_NAME, 81: searchHint: 'modify file contents in place', 82: maxResultSizeChars: 100_000, 83: strict: true, 84: async description() { 85: return 'A tool for editing files' 86: }, 87: async prompt() { 88: return getEditToolDescription() 89: }, 90: userFacingName, 91: getToolUseSummary, 92: getActivityDescription(input) { 93: const summary = getToolUseSummary(input) 94: return summary ? `Editing ${summary}` : 'Editing file' 95: }, 96: get inputSchema() { 97: return inputSchema() 98: }, 99: get outputSchema() { 100: return outputSchema() 101: }, 102: toAutoClassifierInput(input) { 103: return `${input.file_path}: ${input.new_string}` 104: }, 105: getPath(input): string { 106: return input.file_path 107: }, 108: backfillObservableInput(input) { 109: if (typeof input.file_path === 'string') { 110: input.file_path = expandPath(input.file_path) 111: } 112: }, 113: async preparePermissionMatcher({ file_path }) { 114: return pattern => matchWildcardPattern(pattern, file_path) 115: }, 116: async checkPermissions(input, context): Promise<PermissionDecision> { 117: const appState = context.getAppState() 118: return checkWritePermissionForTool( 119: FileEditTool, 120: input, 121: appState.toolPermissionContext, 122: ) 123: }, 124: renderToolUseMessage, 125: renderToolResultMessage, 126: renderToolUseRejectedMessage, 127: renderToolUseErrorMessage, 128: async validateInput(input: FileEditInput, toolUseContext: ToolUseContext) { 129: const { file_path, old_string, new_string, replace_all = false } = input 130: const fullFilePath = expandPath(file_path) 131: const secretError = checkTeamMemSecrets(fullFilePath, new_string) 132: if (secretError) { 133: return { result: false, message: secretError, errorCode: 0 } 134: } 135: if (old_string === new_string) { 136: return { 137: result: false, 138: behavior: 'ask', 139: message: 140: 'No changes to make: old_string and new_string are exactly the same.', 141: errorCode: 1, 142: } 143: } 144: const appState = toolUseContext.getAppState() 145: const denyRule = matchingRuleForInput( 146: fullFilePath, 147: appState.toolPermissionContext, 148: 'edit', 149: 'deny', 150: ) 151: if (denyRule !== null) { 152: return { 153: result: false, 154: behavior: 'ask', 155: message: 156: 'File is in a directory that is denied by your permission settings.', 157: errorCode: 2, 158: } 159: } 160: if (fullFilePath.startsWith('\\\\') || fullFilePath.startsWith(' 161: return { result: true } 162: } 163: const fs = getFsImplementation() 164: try { 165: const { size } = await fs.stat(fullFilePath) 166: if (size > MAX_EDIT_FILE_SIZE) { 167: return { 168: result: false, 169: behavior: 'ask', 170: message: `File is too large to edit (${formatFileSize(size)}). Maximum editable file size is ${formatFileSize(MAX_EDIT_FILE_SIZE)}.`, 171: errorCode: 10, 172: } 173: } 174: } catch (e) { 175: if (!isENOENT(e)) { 176: throw e 177: } 178: } 179: let fileContent: string | null 180: try { 181: const fileBuffer = await fs.readFileBytes(fullFilePath) 182: const encoding: BufferEncoding = 183: fileBuffer.length >= 2 && 184: fileBuffer[0] === 0xff && 185: fileBuffer[1] === 0xfe 186: ? 'utf16le' 187: : 'utf8' 188: fileContent = fileBuffer.toString(encoding).replaceAll('\r\n', '\n') 189: } catch (e) { 190: if (isENOENT(e)) { 191: fileContent = null 192: } else { 193: throw e 194: } 195: } 196: if (fileContent === null) { 197: if (old_string === '') { 198: return { result: true } 199: } 200: // Try to find a similar file with a different extension 201: const similarFilename = findSimilarFile(fullFilePath) 202: const cwdSuggestion = await suggestPathUnderCwd(fullFilePath) 203: let message = `File does not exist. ${FILE_NOT_FOUND_CWD_NOTE} ${getCwd()}.` 204: if (cwdSuggestion) { 205: message += ` Did you mean ${cwdSuggestion}?` 206: } else if (similarFilename) { 207: message += ` Did you mean ${similarFilename}?` 208: } 209: return { 210: result: false, 211: behavior: 'ask', 212: message, 213: errorCode: 4, 214: } 215: } 216: if (old_string === '') { 217: // Only reject if the file has content (for file creation attempt) 218: if (fileContent.trim() !== '') { 219: return { 220: result: false, 221: behavior: 'ask', 222: message: 'Cannot create new file - file already exists.', 223: errorCode: 3, 224: } 225: } 226: return { 227: result: true, 228: } 229: } 230: if (fullFilePath.endsWith('.ipynb')) { 231: return { 232: result: false, 233: behavior: 'ask', 234: message: `File is a Jupyter Notebook. Use the ${NOTEBOOK_EDIT_TOOL_NAME} to edit this file.`, 235: errorCode: 5, 236: } 237: } 238: const readTimestamp = toolUseContext.readFileState.get(fullFilePath) 239: if (!readTimestamp || readTimestamp.isPartialView) { 240: return { 241: result: false, 242: behavior: 'ask', 243: message: 244: 'File has not been read yet. Read it first before writing to it.', 245: meta: { 246: isFilePathAbsolute: String(isAbsolute(file_path)), 247: }, 248: errorCode: 6, 249: } 250: } 251: if (readTimestamp) { 252: const lastWriteTime = getFileModificationTime(fullFilePath) 253: if (lastWriteTime > readTimestamp.timestamp) { 254: const isFullRead = 255: readTimestamp.offset === undefined && 256: readTimestamp.limit === undefined 257: if (isFullRead && fileContent === readTimestamp.content) { 258: } else { 259: return { 260: result: false, 261: behavior: 'ask', 262: message: 263: 'File has been modified since read, either by the user or by a linter. Read it again before attempting to write it.', 264: errorCode: 7, 265: } 266: } 267: } 268: } 269: const file = fileContent 270: const actualOldString = findActualString(file, old_string) 271: if (!actualOldString) { 272: return { 273: result: false, 274: behavior: 'ask', 275: message: `String to replace not found in file.\nString: ${old_string}`, 276: meta: { 277: isFilePathAbsolute: String(isAbsolute(file_path)), 278: }, 279: errorCode: 8, 280: } 281: } 282: const matches = file.split(actualOldString).length - 1 283: if (matches > 1 && !replace_all) { 284: return { 285: result: false, 286: behavior: 'ask', 287: message: `Found ${matches} matches of the string to replace, but replace_all is false. To replace all occurrences, set replace_all to true. To replace only one occurrence, please provide more context to uniquely identify the instance.\nString: ${old_string}`, 288: meta: { 289: isFilePathAbsolute: String(isAbsolute(file_path)), 290: actualOldString, 291: }, 292: errorCode: 9, 293: } 294: } 295: const settingsValidationResult = validateInputForSettingsFileEdit( 296: fullFilePath, 297: file, 298: () => { 299: return replace_all 300: ? file.replaceAll(actualOldString, new_string) 301: : file.replace(actualOldString, new_string) 302: }, 303: ) 304: if (settingsValidationResult !== null) { 305: return settingsValidationResult 306: } 307: return { result: true, meta: { actualOldString } } 308: }, 309: inputsEquivalent(input1, input2) { 310: return areFileEditsInputsEquivalent( 311: { 312: file_path: input1.file_path, 313: edits: [ 314: { 315: old_string: input1.old_string, 316: new_string: input1.new_string, 317: replace_all: input1.replace_all ?? false, 318: }, 319: ], 320: }, 321: { 322: file_path: input2.file_path, 323: edits: [ 324: { 325: old_string: input2.old_string, 326: new_string: input2.new_string, 327: replace_all: input2.replace_all ?? false, 328: }, 329: ], 330: }, 331: ) 332: }, 333: async call( 334: input: FileEditInput, 335: { 336: readFileState, 337: userModified, 338: updateFileHistoryState, 339: dynamicSkillDirTriggers, 340: }, 341: _, 342: parentMessage, 343: ) { 344: const { file_path, old_string, new_string, replace_all = false } = input 345: const fs = getFsImplementation() 346: const absoluteFilePath = expandPath(file_path) 347: const cwd = getCwd() 348: if (!isEnvTruthy(process.env.CLAUDE_CODE_SIMPLE)) { 349: const newSkillDirs = await discoverSkillDirsForPaths( 350: [absoluteFilePath], 351: cwd, 352: ) 353: if (newSkillDirs.length > 0) { 354: for (const dir of newSkillDirs) { 355: dynamicSkillDirTriggers?.add(dir) 356: } 357: addSkillDirectories(newSkillDirs).catch(() => {}) 358: } 359: activateConditionalSkillsForPaths([absoluteFilePath], cwd) 360: } 361: await diagnosticTracker.beforeFileEdited(absoluteFilePath) 362: await fs.mkdir(dirname(absoluteFilePath)) 363: if (fileHistoryEnabled()) { 364: await fileHistoryTrackEdit( 365: updateFileHistoryState, 366: absoluteFilePath, 367: parentMessage.uuid, 368: ) 369: } 370: const { 371: content: originalFileContents, 372: fileExists, 373: encoding, 374: lineEndings: endings, 375: } = readFileForEdit(absoluteFilePath) 376: if (fileExists) { 377: const lastWriteTime = getFileModificationTime(absoluteFilePath) 378: const lastRead = readFileState.get(absoluteFilePath) 379: if (!lastRead || lastWriteTime > lastRead.timestamp) { 380: const isFullRead = 381: lastRead && 382: lastRead.offset === undefined && 383: lastRead.limit === undefined 384: const contentUnchanged = 385: isFullRead && originalFileContents === lastRead.content 386: if (!contentUnchanged) { 387: throw new Error(FILE_UNEXPECTEDLY_MODIFIED_ERROR) 388: } 389: } 390: } 391: const actualOldString = 392: findActualString(originalFileContents, old_string) || old_string 393: const actualNewString = preserveQuoteStyle( 394: old_string, 395: actualOldString, 396: new_string, 397: ) 398: const { patch, updatedFile } = getPatchForEdit({ 399: filePath: absoluteFilePath, 400: fileContents: originalFileContents, 401: oldString: actualOldString, 402: newString: actualNewString, 403: replaceAll: replace_all, 404: }) 405: writeTextContent(absoluteFilePath, updatedFile, encoding, endings) 406: const lspManager = getLspServerManager() 407: if (lspManager) { 408: clearDeliveredDiagnosticsForFile(`file://${absoluteFilePath}`) 409: lspManager 410: .changeFile(absoluteFilePath, updatedFile) 411: .catch((err: Error) => { 412: logForDebugging( 413: `LSP: Failed to notify server of file change for ${absoluteFilePath}: ${err.message}`, 414: ) 415: logError(err) 416: }) 417: lspManager.saveFile(absoluteFilePath).catch((err: Error) => { 418: logForDebugging( 419: `LSP: Failed to notify server of file save for ${absoluteFilePath}: ${err.message}`, 420: ) 421: logError(err) 422: }) 423: } 424: notifyVscodeFileUpdated(absoluteFilePath, originalFileContents, updatedFile) 425: readFileState.set(absoluteFilePath, { 426: content: updatedFile, 427: timestamp: getFileModificationTime(absoluteFilePath), 428: offset: undefined, 429: limit: undefined, 430: }) 431: if (absoluteFilePath.endsWith(`${sep}CLAUDE.md`)) { 432: logEvent('tengu_write_claudemd', {}) 433: } 434: countLinesChanged(patch) 435: logFileOperation({ 436: operation: 'edit', 437: tool: 'FileEditTool', 438: filePath: absoluteFilePath, 439: }) 440: logEvent('tengu_edit_string_lengths', { 441: oldStringBytes: Buffer.byteLength(old_string, 'utf8'), 442: newStringBytes: Buffer.byteLength(new_string, 'utf8'), 443: replaceAll: replace_all, 444: }) 445: let gitDiff: ToolUseDiff | undefined 446: if ( 447: isEnvTruthy(process.env.CLAUDE_CODE_REMOTE) && 448: getFeatureValue_CACHED_MAY_BE_STALE('tengu_quartz_lantern', false) 449: ) { 450: const startTime = Date.now() 451: const diff = await fetchSingleFileGitDiff(absoluteFilePath) 452: if (diff) gitDiff = diff 453: logEvent('tengu_tool_use_diff_computed', { 454: isEditTool: true, 455: durationMs: Date.now() - startTime, 456: hasDiff: !!diff, 457: }) 458: } 459: const data = { 460: filePath: file_path, 461: oldString: actualOldString, 462: newString: new_string, 463: originalFile: originalFileContents, 464: structuredPatch: patch, 465: userModified: userModified ?? false, 466: replaceAll: replace_all, 467: ...(gitDiff && { gitDiff }), 468: } 469: return { 470: data, 471: } 472: }, 473: mapToolResultToToolResultBlockParam(data: FileEditOutput, toolUseID) { 474: const { filePath, userModified, replaceAll } = data 475: const modifiedNote = userModified 476: ? '. The user modified your proposed changes before accepting them. ' 477: : '' 478: if (replaceAll) { 479: return { 480: tool_use_id: toolUseID, 481: type: 'tool_result', 482: content: `The file ${filePath} has been updated${modifiedNote}. All occurrences were successfully replaced.`, 483: } 484: } 485: return { 486: tool_use_id: toolUseID, 487: type: 'tool_result', 488: content: `The file ${filePath} has been updated successfully${modifiedNote}.`, 489: } 490: }, 491: } satisfies ToolDef<ReturnType<typeof inputSchema>, FileEditOutput>) 492: function readFileForEdit(absoluteFilePath: string): { 493: content: string 494: fileExists: boolean 495: encoding: BufferEncoding 496: lineEndings: LineEndingType 497: } { 498: try { 499: const meta = readFileSyncWithMetadata(absoluteFilePath) 500: return { 501: content: meta.content, 502: fileExists: true, 503: encoding: meta.encoding, 504: lineEndings: meta.lineEndings, 505: } 506: } catch (e) { 507: if (isENOENT(e)) { 508: return { 509: content: '', 510: fileExists: false, 511: encoding: 'utf8', 512: lineEndings: 'LF', 513: } 514: } 515: throw e 516: } 517: }

File: src/tools/FileEditTool/prompt.ts

typescript 1: import { isCompactLinePrefixEnabled } from '../../utils/file.js' 2: import { FILE_READ_TOOL_NAME } from '../FileReadTool/prompt.js' 3: function getPreReadInstruction(): string { 4: return `\n- You must use your \`${FILE_READ_TOOL_NAME}\` tool at least once in the conversation before editing. This tool will error if you attempt an edit without reading the file. ` 5: } 6: export function getEditToolDescription(): string { 7: return getDefaultEditDescription() 8: } 9: function getDefaultEditDescription(): string { 10: const prefixFormat = isCompactLinePrefixEnabled() 11: ? 'line number + tab' 12: : 'spaces + line number + arrow' 13: const minimalUniquenessHint = 14: process.env.USER_TYPE === 'ant' 15: ? `\n- Use the smallest old_string that's clearly unique — usually 2-4 adjacent lines is sufficient. Avoid including 10+ lines of context when less uniquely identifies the target.` 16: : '' 17: return `Performs exact string replacements in files. 18: Usage:${getPreReadInstruction()} 19: - When editing text from Read tool output, ensure you preserve the exact indentation (tabs/spaces) as it appears AFTER the line number prefix. The line number prefix format is: ${prefixFormat}. Everything after that is the actual file content to match. Never include any part of the line number prefix in the old_string or new_string. 20: - ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required. 21: - Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked. 22: - The edit will FAIL if \`old_string\` is not unique in the file. Either provide a larger string with more surrounding context to make it unique or use \`replace_all\` to change every instance of \`old_string\`.${minimalUniquenessHint} 23: - Use \`replace_all\` for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance.` 24: }

File: src/tools/FileEditTool/types.ts

typescript 1: import { z } from 'zod/v4' 2: import { lazySchema } from '../../utils/lazySchema.js' 3: import { semanticBoolean } from '../../utils/semanticBoolean.js' 4: const inputSchema = lazySchema(() => 5: z.strictObject({ 6: file_path: z.string().describe('The absolute path to the file to modify'), 7: old_string: z.string().describe('The text to replace'), 8: new_string: z 9: .string() 10: .describe( 11: 'The text to replace it with (must be different from old_string)', 12: ), 13: replace_all: semanticBoolean( 14: z.boolean().default(false).optional(), 15: ).describe('Replace all occurrences of old_string (default false)'), 16: }), 17: ) 18: type InputSchema = ReturnType<typeof inputSchema> 19: export type FileEditInput = z.output<InputSchema> 20: export type EditInput = Omit<FileEditInput, 'file_path'> 21: export type FileEdit = { 22: old_string: string 23: new_string: string 24: replace_all: boolean 25: } 26: export const hunkSchema = lazySchema(() => 27: z.object({ 28: oldStart: z.number(), 29: oldLines: z.number(), 30: newStart: z.number(), 31: newLines: z.number(), 32: lines: z.array(z.string()), 33: }), 34: ) 35: export const gitDiffSchema = lazySchema(() => 36: z.object({ 37: filename: z.string(), 38: status: z.enum(['modified', 'added']), 39: additions: z.number(), 40: deletions: z.number(), 41: changes: z.number(), 42: patch: z.string(), 43: repository: z 44: .string() 45: .nullable() 46: .optional() 47: .describe('GitHub owner/repo when available'), 48: }), 49: ) 50: const outputSchema = lazySchema(() => 51: z.object({ 52: filePath: z.string().describe('The file path that was edited'), 53: oldString: z.string().describe('The original string that was replaced'), 54: newString: z.string().describe('The new string that replaced it'), 55: originalFile: z 56: .string() 57: .describe('The original file contents before editing'), 58: structuredPatch: z 59: .array(hunkSchema()) 60: .describe('Diff patch showing the changes'), 61: userModified: z 62: .boolean() 63: .describe('Whether the user modified the proposed changes'), 64: replaceAll: z.boolean().describe('Whether all occurrences were replaced'), 65: gitDiff: gitDiffSchema().optional(), 66: }), 67: ) 68: type OutputSchema = ReturnType<typeof outputSchema> 69: export type FileEditOutput = z.infer<OutputSchema> 70: export { inputSchema, outputSchema }

File: src/tools/FileEditTool/UI.tsx

typescript 1: import { c as _c } from "react/compiler-runtime"; 2: import type { ToolResultBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'; 3: import type { StructuredPatchHunk } from 'diff'; 4: import * as React from 'react'; 5: import { Suspense, use, useState } from 'react'; 6: import { FileEditToolUseRejectedMessage } from 'src/components/FileEditToolUseRejectedMessage.js'; 7: import { MessageResponse } from 'src/components/MessageResponse.js'; 8: import { extractTag } from 'src/utils/messages.js'; 9: import { FallbackToolUseErrorMessage } from '../../components/FallbackToolUseErrorMessage.js'; 10: import { FileEditToolUpdatedMessage } from '../../components/FileEditToolUpdatedMessage.js'; 11: import { FilePathLink } from '../../components/FilePathLink.js'; 12: import { Text } from '../../ink.js'; 13: import type { Tools } from '../../Tool.js'; 14: import type { Message, ProgressMessage } from '../../types/message.js'; 15: import { adjustHunkLineNumbers, CONTEXT_LINES } from '../../utils/diff.js'; 16: import { FILE_NOT_FOUND_CWD_NOTE, getDisplayPath } from '../../utils/file.js'; 17: import { logError } from '../../utils/log.js'; 18: import { getPlansDirectory } from '../../utils/plans.js'; 19: import { readEditContext } from '../../utils/readEditContext.js'; 20: import { firstLineOf } from '../../utils/stringUtils.js'; 21: import type { ThemeName } from '../../utils/theme.js'; 22: import type { FileEditOutput } from './types.js'; 23: import { findActualString, getPatchForEdit, preserveQuoteStyle } from './utils.js'; 24: export function userFacingName(input: Partial<{ 25: file_path: string; 26: old_string: string; 27: new_string: string; 28: replace_all: boolean; 29: edits: unknown[]; 30: }> | undefined): string { 31: if (!input) { 32: return 'Update'; 33: } 34: if (input.file_path?.startsWith(getPlansDirectory())) { 35: return 'Updated plan'; 36: } 37: if (input.edits != null) { 38: return 'Update'; 39: } 40: if (input.old_string === '') { 41: return 'Create'; 42: } 43: return 'Update'; 44: } 45: export function getToolUseSummary(input: Partial<{ 46: file_path: string; 47: old_string: string; 48: new_string: string; 49: replace_all: boolean; 50: }> | undefined): string | null { 51: if (!input?.file_path) { 52: return null; 53: } 54: return getDisplayPath(input.file_path); 55: } 56: export function renderToolUseMessage({ 57: file_path 58: }: { 59: file_path?: string; 60: }, { 61: verbose 62: }: { 63: verbose: boolean; 64: }): React.ReactNode { 65: if (!file_path) { 66: return null; 67: } 68: if (file_path.startsWith(getPlansDirectory())) { 69: return ''; 70: } 71: return <FilePathLink filePath={file_path}> 72: {verbose ? file_path : getDisplayPath(file_path)} 73: </FilePathLink>; 74: } 75: export function renderToolResultMessage({ 76: filePath, 77: structuredPatch, 78: originalFile 79: }: FileEditOutput, _progressMessagesForMessage: ProgressMessage[], { 80: style, 81: verbose 82: }: { 83: style?: 'condensed'; 84: verbose: boolean; 85: }): React.ReactNode { 86: const isPlanFile = filePath.startsWith(getPlansDirectory()); 87: return <FileEditToolUpdatedMessage filePath={filePath} structuredPatch={structuredPatch} firstLine={originalFile.split('\n')[0] ?? null} fileContent={originalFile} style={style} verbose={verbose} previewHint={isPlanFile ? '/plan to preview' : undefined} />; 88: } 89: export function renderToolUseRejectedMessage(input: { 90: file_path: string; 91: old_string?: string; 92: new_string?: string; 93: replace_all?: boolean; 94: edits?: unknown[]; 95: }, options: { 96: columns: number; 97: messages: Message[]; 98: progressMessagesForMessage: ProgressMessage[]; 99: style?: 'condensed'; 100: theme: ThemeName; 101: tools: Tools; 102: verbose: boolean; 103: }): React.ReactElement { 104: const { 105: style, 106: verbose 107: } = options; 108: const filePath = input.file_path; 109: const oldString = input.old_string ?? ''; 110: const newString = input.new_string ?? ''; 111: const replaceAll = input.replace_all ?? false; 112: // Defensive: if input has an unexpected shape, show a simple rejection message 113: if ('edits' in input && input.edits != null) { 114: return <FileEditToolUseRejectedMessage file_path={filePath} operation="update" firstLine={null} verbose={verbose} />; 115: } 116: const isNewFile = oldString === ''; 117: // For new file creation, show content preview instead of diff 118: if (isNewFile) { 119: return <FileEditToolUseRejectedMessage file_path={filePath} operation="write" content={newString} firstLine={firstLineOf(newString)} verbose={verbose} />; 120: } 121: return <EditRejectionDiff filePath={filePath} oldString={oldString} newString={newString} replaceAll={replaceAll} style={style} verbose={verbose} />; 122: } 123: export function renderToolUseErrorMessage(result: ToolResultBlockParam['content'], options: { 124: progressMessagesForMessage: ProgressMessage[]; 125: tools: Tools; 126: verbose: boolean; 127: }): React.ReactElement { 128: const { 129: verbose 130: } = options; 131: if (!verbose && typeof result === 'string' && extractTag(result, 'tool_use_error')) { 132: const errorMessage = extractTag(result, 'tool_use_error'); 133: if (errorMessage?.includes('File has not been read yet')) { 134: return <MessageResponse> 135: <Text dimColor>File must be read first</Text> 136: </MessageResponse>; 137: } 138: if (errorMessage?.includes(FILE_NOT_FOUND_CWD_NOTE)) { 139: return <MessageResponse> 140: <Text color="error">File not found</Text> 141: </MessageResponse>; 142: } 143: return <MessageResponse> 144: <Text color="error">Error editing file</Text> 145: </MessageResponse>; 146: } 147: return <FallbackToolUseErrorMessage result={result} verbose={verbose} />; 148: } 149: type RejectionDiffData = { 150: patch: StructuredPatchHunk[]; 151: firstLine: string | null; 152: fileContent: string | undefined; 153: }; 154: function EditRejectionDiff(t0) { 155: const $ = _c(16); 156: const { 157: filePath, 158: oldString, 159: newString, 160: replaceAll, 161: style, 162: verbose 163: } = t0; 164: let t1; 165: if ($[0] !== filePath || $[1] !== newString || $[2] !== oldString || $[3] !== replaceAll) { 166: t1 = () => loadRejectionDiff(filePath, oldString, newString, replaceAll); 167: $[0] = filePath; 168: $[1] = newString; 169: $[2] = oldString; 170: $[3] = replaceAll; 171: $[4] = t1; 172: } else { 173: t1 = $[4]; 174: } 175: const [dataPromise] = useState(t1); 176: let t2; 177: if ($[5] !== filePath || $[6] !== verbose) { 178: t2 = <FileEditToolUseRejectedMessage file_path={filePath} operation="update" firstLine={null} verbose={verbose} />; 179: $[5] = filePath; 180: $[6] = verbose; 181: $[7] = t2; 182: } else { 183: t2 = $[7]; 184: } 185: let t3; 186: if ($[8] !== dataPromise || $[9] !== filePath || $[10] !== style || $[11] !== verbose) { 187: t3 = <EditRejectionBody promise={dataPromise} filePath={filePath} style={style} verbose={verbose} />; 188: $[8] = dataPromise; 189: $[9] = filePath; 190: $[10] = style; 191: $[11] = verbose; 192: $[12] = t3; 193: } else { 194: t3 = $[12]; 195: } 196: let t4; 197: if ($[13] !== t2 || $[14] !== t3) { 198: t4 = <Suspense fallback={t2}>{t3}</Suspense>; 199: $[13] = t2; 200: $[14] = t3; 201: $[15] = t4; 202: } else { 203: t4 = $[15]; 204: } 205: return t4; 206: } 207: function EditRejectionBody(t0) { 208: const $ = _c(7); 209: const { 210: promise, 211: filePath, 212: style, 213: verbose 214: } = t0; 215: const { 216: patch, 217: firstLine, 218: fileContent 219: } = use(promise); 220: let t1; 221: if ($[0] !== fileContent || $[1] !== filePath || $[2] !== firstLine || $[3] !== patch || $[4] !== style || $[5] !== verbose) { 222: t1 = <FileEditToolUseRejectedMessage file_path={filePath} operation="update" patch={patch} firstLine={firstLine} fileContent={fileContent} style={style} verbose={verbose} />; 223: $[0] = fileContent; 224: $[1] = filePath; 225: $[2] = firstLine; 226: $[3] = patch; 227: $[4] = style; 228: $[5] = verbose; 229: $[6] = t1; 230: } else { 231: t1 = $[6]; 232: } 233: return t1; 234: } 235: async function loadRejectionDiff(filePath: string, oldString: string, newString: string, replaceAll: boolean): Promise<RejectionDiffData> { 236: try { 237: const ctx = await readEditContext(filePath, oldString, CONTEXT_LINES); 238: if (ctx === null || ctx.truncated || ctx.content === '') { 239: const { 240: patch 241: } = getPatchForEdit({ 242: filePath, 243: fileContents: oldString, 244: oldString, 245: newString 246: }); 247: return { 248: patch, 249: firstLine: null, 250: fileContent: undefined 251: }; 252: } 253: const actualOld = findActualString(ctx.content, oldString) || oldString; 254: const actualNew = preserveQuoteStyle(oldString, actualOld, newString); 255: const { 256: patch 257: } = getPatchForEdit({ 258: filePath, 259: fileContents: ctx.content, 260: oldString: actualOld, 261: newString: actualNew, 262: replaceAll 263: }); 264: return { 265: patch: adjustHunkLineNumbers(patch, ctx.lineOffset - 1), 266: firstLine: ctx.lineOffset === 1 ? firstLineOf(ctx.content) : null, 267: fileContent: ctx.content 268: }; 269: } catch (e) { 270: logError(e as Error); 271: return { 272: patch: [], 273: firstLine: null, 274: fileContent: undefined 275: }; 276: } 277: }

File: src/tools/FileEditTool/utils.ts

typescript 1: import { type StructuredPatchHunk, structuredPatch } from 'diff' 2: import { logError } from 'src/utils/log.js' 3: import { expandPath } from 'src/utils/path.js' 4: import { countCharInString } from 'src/utils/stringUtils.js' 5: import { 6: DIFF_TIMEOUT_MS, 7: getPatchForDisplay, 8: getPatchFromContents, 9: } from '../../utils/diff.js' 10: import { errorMessage, isENOENT } from '../../utils/errors.js' 11: import { 12: addLineNumbers, 13: convertLeadingTabsToSpaces, 14: readFileSyncCached, 15: } from '../../utils/file.js' 16: import type { EditInput, FileEdit } from './types.js' 17: export const LEFT_SINGLE_CURLY_QUOTE = '‘' 18: export const RIGHT_SINGLE_CURLY_QUOTE = '’' 19: export const LEFT_DOUBLE_CURLY_QUOTE = '“' 20: export const RIGHT_DOUBLE_CURLY_QUOTE = '”' 21: export function normalizeQuotes(str: string): string { 22: return str 23: .replaceAll(LEFT_SINGLE_CURLY_QUOTE, "'") 24: .replaceAll(RIGHT_SINGLE_CURLY_QUOTE, "'") 25: .replaceAll(LEFT_DOUBLE_CURLY_QUOTE, '"') 26: .replaceAll(RIGHT_DOUBLE_CURLY_QUOTE, '"') 27: } 28: export function stripTrailingWhitespace(str: string): string { 29: const lines = str.split(/(\r\n|\n|\r)/) 30: let result = '' 31: for (let i = 0; i < lines.length; i++) { 32: const part = lines[i] 33: if (part !== undefined) { 34: if (i % 2 === 0) { 35: // Even indices are line content 36: result += part.replace(/\s+$/, '') 37: } else { 38: // Odd indices are line endings 39: result += part 40: } 41: } 42: } 43: return result 44: } 45: /** 46: * Finds the actual string in the file content that matches the search string, 47: * accounting for quote normalization 48: * @param fileContent The file content to search in 49: * @param searchString The string to search for 50: * @returns The actual string found in the file, or null if not found 51: */ 52: export function findActualString( 53: fileContent: string, 54: searchString: string, 55: ): string | null { 56: // First try exact match 57: if (fileContent.includes(searchString)) { 58: return searchString 59: } 60: // Try with normalized quotes 61: const normalizedSearch = normalizeQuotes(searchString) 62: const normalizedFile = normalizeQuotes(fileContent) 63: const searchIndex = normalizedFile.indexOf(normalizedSearch) 64: if (searchIndex !== -1) { 65: // Find the actual string in the file that matches 66: return fileContent.substring(searchIndex, searchIndex + searchString.length) 67: } 68: return null 69: } 70: /** 71: * When old_string matched via quote normalization (curly quotes in file, 72: * straight quotes from model), apply the same curly quote style to new_string 73: * so the edit preserves the file's typography. 74: * 75: * Uses a simple open/close heuristic: a quote character preceded by whitespace, 76: * start of string, or opening punctuation is treated as an opening quote; 77: * otherwise it's a closing quote. 78: */ 79: export function preserveQuoteStyle( 80: oldString: string, 81: actualOldString: string, 82: newString: string, 83: ): string { 84: if (oldString === actualOldString) { 85: return newString 86: } 87: const hasDoubleQuotes = 88: actualOldString.includes(LEFT_DOUBLE_CURLY_QUOTE) || 89: actualOldString.includes(RIGHT_DOUBLE_CURLY_QUOTE) 90: const hasSingleQuotes = 91: actualOldString.includes(LEFT_SINGLE_CURLY_QUOTE) || 92: actualOldString.includes(RIGHT_SINGLE_CURLY_QUOTE) 93: if (!hasDoubleQuotes && !hasSingleQuotes) { 94: return newString 95: } 96: let result = newString 97: if (hasDoubleQuotes) { 98: result = applyCurlyDoubleQuotes(result) 99: } 100: if (hasSingleQuotes) { 101: result = applyCurlySingleQuotes(result) 102: } 103: return result 104: } 105: function isOpeningContext(chars: string[], index: number): boolean { 106: if (index === 0) { 107: return true 108: } 109: const prev = chars[index - 1] 110: return ( 111: prev === ' ' || 112: prev === '\t' || 113: prev === '\n' || 114: prev === '\r' || 115: prev === '(' || 116: prev === '[' || 117: prev === '{' || 118: prev === '\u2014' || 119: prev === '\u2013' 120: ) 121: } 122: function applyCurlyDoubleQuotes(str: string): string { 123: const chars = [...str] 124: const result: string[] = [] 125: for (let i = 0; i < chars.length; i++) { 126: if (chars[i] === '"') { 127: result.push( 128: isOpeningContext(chars, i) 129: ? LEFT_DOUBLE_CURLY_QUOTE 130: : RIGHT_DOUBLE_CURLY_QUOTE, 131: ) 132: } else { 133: result.push(chars[i]!) 134: } 135: } 136: return result.join('') 137: } 138: function applyCurlySingleQuotes(str: string): string { 139: const chars = [...str] 140: const result: string[] = [] 141: for (let i = 0; i < chars.length; i++) { 142: if (chars[i] === "'") { 143: // Don't convert apostrophes in contractions (e.g., "don't", "it's") 144: const prev = i > 0 ? chars[i - 1] : undefined 145: const next = i < chars.length - 1 ? chars[i + 1] : undefined 146: const prevIsLetter = prev !== undefined && /\p{L}/u.test(prev) 147: const nextIsLetter = next !== undefined && /\p{L}/u.test(next) 148: if (prevIsLetter && nextIsLetter) { 149: result.push(RIGHT_SINGLE_CURLY_QUOTE) 150: } else { 151: result.push( 152: isOpeningContext(chars, i) 153: ? LEFT_SINGLE_CURLY_QUOTE 154: : RIGHT_SINGLE_CURLY_QUOTE, 155: ) 156: } 157: } else { 158: result.push(chars[i]!) 159: } 160: } 161: return result.join('') 162: } 163: /** 164: * Transform edits to ensure replace_all always has a boolean value 165: * @param edits Array of edits with optional replace_all 166: * @returns Array of edits with replace_all guaranteed to be boolean 167: */ 168: export function applyEditToFile( 169: originalContent: string, 170: oldString: string, 171: newString: string, 172: replaceAll: boolean = false, 173: ): string { 174: const f = replaceAll 175: ? (content: string, search: string, replace: string) => 176: content.replaceAll(search, () => replace) 177: : (content: string, search: string, replace: string) => 178: content.replace(search, () => replace) 179: if (newString !== '') { 180: return f(originalContent, oldString, newString) 181: } 182: const stripTrailingNewline = 183: !oldString.endsWith('\n') && originalContent.includes(oldString + '\n') 184: return stripTrailingNewline 185: ? f(originalContent, oldString + '\n', newString) 186: : f(originalContent, oldString, newString) 187: } 188: export function getPatchForEdit({ 189: filePath, 190: fileContents, 191: oldString, 192: newString, 193: replaceAll = false, 194: }: { 195: filePath: string 196: fileContents: string 197: oldString: string 198: newString: string 199: replaceAll?: boolean 200: }): { patch: StructuredPatchHunk[]; updatedFile: string } { 201: return getPatchForEdits({ 202: filePath, 203: fileContents, 204: edits: [ 205: { old_string: oldString, new_string: newString, replace_all: replaceAll }, 206: ], 207: }) 208: } 209: export function getPatchForEdits({ 210: filePath, 211: fileContents, 212: edits, 213: }: { 214: filePath: string 215: fileContents: string 216: edits: FileEdit[] 217: }): { patch: StructuredPatchHunk[]; updatedFile: string } { 218: let updatedFile = fileContents 219: const appliedNewStrings: string[] = [] 220: if ( 221: !fileContents && 222: edits.length === 1 && 223: edits[0] && 224: edits[0].old_string === '' && 225: edits[0].new_string === '' 226: ) { 227: const patch = getPatchForDisplay({ 228: filePath, 229: fileContents, 230: edits: [ 231: { 232: old_string: fileContents, 233: new_string: updatedFile, 234: replace_all: false, 235: }, 236: ], 237: }) 238: return { patch, updatedFile: '' } 239: } 240: // Apply each edit and check if it actually changes the file 241: for (const edit of edits) { 242: // Strip trailing newlines from old_string before checking 243: const oldStringToCheck = edit.old_string.replace(/\n+$/, '') 244: // Check if old_string is a substring of any previously applied new_string 245: for (const previousNewString of appliedNewStrings) { 246: if ( 247: oldStringToCheck !== '' && 248: previousNewString.includes(oldStringToCheck) 249: ) { 250: throw new Error( 251: 'Cannot edit file: old_string is a substring of a new_string from a previous edit.', 252: ) 253: } 254: } 255: const previousContent = updatedFile 256: updatedFile = 257: edit.old_string === '' 258: ? edit.new_string 259: : applyEditToFile( 260: updatedFile, 261: edit.old_string, 262: edit.new_string, 263: edit.replace_all, 264: ) 265: // If this edit didn't change anything, throw an error 266: if (updatedFile === previousContent) { 267: throw new Error('String not found in file. Failed to apply edit.') 268: } 269: appliedNewStrings.push(edit.new_string) 270: } 271: if (updatedFile === fileContents) { 272: throw new Error( 273: 'Original and edited file match exactly. Failed to apply edit.', 274: ) 275: } 276: const patch = getPatchFromContents({ 277: filePath, 278: oldContent: convertLeadingTabsToSpaces(fileContents), 279: newContent: convertLeadingTabsToSpaces(updatedFile), 280: }) 281: return { patch, updatedFile } 282: } 283: const DIFF_SNIPPET_MAX_BYTES = 8192 284: export function getSnippetForTwoFileDiff( 285: fileAContents: string, 286: fileBContents: string, 287: ): string { 288: const patch = structuredPatch( 289: 'file.txt', 290: 'file.txt', 291: fileAContents, 292: fileBContents, 293: undefined, 294: undefined, 295: { 296: context: 8, 297: timeout: DIFF_TIMEOUT_MS, 298: }, 299: ) 300: if (!patch) { 301: return '' 302: } 303: const full = patch.hunks 304: .map(_ => ({ 305: startLine: _.oldStart, 306: content: _.lines 307: // Filter out deleted lines AND diff metadata lines 308: .filter(_ => !_.startsWith('-') && !_.startsWith('\\')) 309: .map(_ => _.slice(1)) 310: .join('\n'), 311: })) 312: .map(addLineNumbers) 313: .join('\n...\n') 314: if (full.length <= DIFF_SNIPPET_MAX_BYTES) { 315: return full 316: } 317: const cutoff = full.lastIndexOf('\n', DIFF_SNIPPET_MAX_BYTES) 318: const kept = 319: cutoff > 0 ? full.slice(0, cutoff) : full.slice(0, DIFF_SNIPPET_MAX_BYTES) 320: const remaining = countCharInString(full, '\n', kept.length) + 1 321: return `${kept}\n\n... [${remaining} lines truncated] ...` 322: } 323: const CONTEXT_LINES = 4 324: export function getSnippetForPatch( 325: patch: StructuredPatchHunk[], 326: newFile: string, 327: ): { formattedSnippet: string; startLine: number } { 328: if (patch.length === 0) { 329: return { formattedSnippet: '', startLine: 1 } 330: } 331: // Find the first and last changed lines across all hunks 332: let minLine = Infinity 333: let maxLine = -Infinity 334: for (const hunk of patch) { 335: if (hunk.oldStart < minLine) { 336: minLine = hunk.oldStart 337: } 338: // For the end line, we need to consider the new lines count since we're showing the new file 339: const hunkEnd = hunk.oldStart + (hunk.newLines || 0) - 1 340: if (hunkEnd > maxLine) { 341: maxLine = hunkEnd 342: } 343: } 344: const startLine = Math.max(1, minLine - CONTEXT_LINES) 345: const endLine = maxLine + CONTEXT_LINES 346: const fileLines = newFile.split(/\r?\n/) 347: const snippetLines = fileLines.slice(startLine - 1, endLine) 348: const snippet = snippetLines.join('\n') 349: const formattedSnippet = addLineNumbers({ 350: content: snippet, 351: startLine, 352: }) 353: return { formattedSnippet, startLine } 354: } 355: export function getSnippet( 356: originalFile: string, 357: oldString: string, 358: newString: string, 359: contextLines: number = 4, 360: ): { snippet: string; startLine: number } { 361: const before = originalFile.split(oldString)[0] ?? '' 362: const replacementLine = before.split(/\r?\n/).length - 1 363: const newFileLines = applyEditToFile( 364: originalFile, 365: oldString, 366: newString, 367: ).split(/\r?\n/) 368: // Calculate the start and end line numbers for the snippet 369: const startLine = Math.max(0, replacementLine - contextLines) 370: const endLine = 371: replacementLine + contextLines + newString.split(/\r?\n/).length 372: // Get snippet 373: const snippetLines = newFileLines.slice(startLine, endLine) 374: const snippet = snippetLines.join('\n') 375: return { snippet, startLine: startLine + 1 } 376: } 377: export function getEditsForPatch(patch: StructuredPatchHunk[]): FileEdit[] { 378: return patch.map(hunk => { 379: const contextLines: string[] = [] 380: const oldLines: string[] = [] 381: const newLines: string[] = [] 382: for (const line of hunk.lines) { 383: if (line.startsWith(' ')) { 384: contextLines.push(line.slice(1)) 385: oldLines.push(line.slice(1)) 386: newLines.push(line.slice(1)) 387: } else if (line.startsWith('-')) { 388: oldLines.push(line.slice(1)) 389: } else if (line.startsWith('+')) { 390: newLines.push(line.slice(1)) 391: } 392: } 393: return { 394: old_string: oldLines.join('\n'), 395: new_string: newLines.join('\n'), 396: replace_all: false, 397: } 398: }) 399: } 400: const DESANITIZATIONS: Record<string, string> = { 401: '<fnr>': '<function_results>', 402: '<n>': '<name>', 403: '</n>': '</name>', 404: '<o>': '<output>', 405: '</o>': '</output>', 406: '<e>': '<error>', 407: '</e>': '</error>', 408: '<s>': '<system>', 409: '</s>': '</system>', 410: '<r>': '<result>', 411: '</r>': '</result>', 412: '< META_START >': '<META_START>', 413: '< META_END >': '<META_END>', 414: '< EOT >': '<EOT>', 415: '< META >': '<META>', 416: '< SOS >': '<SOS>', 417: '\n\nH:': '\n\nHuman:', 418: '\n\nA:': '\n\nAssistant:', 419: } 420: function desanitizeMatchString(matchString: string): { 421: result: string 422: appliedReplacements: Array<{ from: string; to: string }> 423: } { 424: let result = matchString 425: const appliedReplacements: Array<{ from: string; to: string }> = [] 426: for (const [from, to] of Object.entries(DESANITIZATIONS)) { 427: const beforeReplace = result 428: result = result.replaceAll(from, to) 429: if (beforeReplace !== result) { 430: appliedReplacements.push({ from, to }) 431: } 432: } 433: return { result, appliedReplacements } 434: } 435: export function normalizeFileEditInput({ 436: file_path, 437: edits, 438: }: { 439: file_path: string 440: edits: EditInput[] 441: }): { 442: file_path: string 443: edits: EditInput[] 444: } { 445: if (edits.length === 0) { 446: return { file_path, edits } 447: } 448: const isMarkdown = /\.(md|mdx)$/i.test(file_path) 449: try { 450: const fullPath = expandPath(file_path) 451: const fileContent = readFileSyncCached(fullPath) 452: return { 453: file_path, 454: edits: edits.map(({ old_string, new_string, replace_all }) => { 455: const normalizedNewString = isMarkdown 456: ? new_string 457: : stripTrailingWhitespace(new_string) 458: if (fileContent.includes(old_string)) { 459: return { 460: old_string, 461: new_string: normalizedNewString, 462: replace_all, 463: } 464: } 465: const { result: desanitizedOldString, appliedReplacements } = 466: desanitizeMatchString(old_string) 467: if (fileContent.includes(desanitizedOldString)) { 468: let desanitizedNewString = normalizedNewString 469: for (const { from, to } of appliedReplacements) { 470: desanitizedNewString = desanitizedNewString.replaceAll(from, to) 471: } 472: return { 473: old_string: desanitizedOldString, 474: new_string: desanitizedNewString, 475: replace_all, 476: } 477: } 478: return { 479: old_string, 480: new_string: normalizedNewString, 481: replace_all, 482: } 483: }), 484: } 485: } catch (error) { 486: if (!isENOENT(error)) { 487: logError(error) 488: } 489: } 490: return { file_path, edits } 491: } 492: export function areFileEditsEquivalent( 493: edits1: FileEdit[], 494: edits2: FileEdit[], 495: originalContent: string, 496: ): boolean { 497: if ( 498: edits1.length === edits2.length && 499: edits1.every((edit1, index) => { 500: const edit2 = edits2[index] 501: return ( 502: edit2 !== undefined && 503: edit1.old_string === edit2.old_string && 504: edit1.new_string === edit2.new_string && 505: edit1.replace_all === edit2.replace_all 506: ) 507: }) 508: ) { 509: return true 510: } 511: let result1: { patch: StructuredPatchHunk[]; updatedFile: string } | null = 512: null 513: let error1: string | null = null 514: let result2: { patch: StructuredPatchHunk[]; updatedFile: string } | null = 515: null 516: let error2: string | null = null 517: try { 518: result1 = getPatchForEdits({ 519: filePath: 'temp', 520: fileContents: originalContent, 521: edits: edits1, 522: }) 523: } catch (e) { 524: error1 = errorMessage(e) 525: } 526: try { 527: result2 = getPatchForEdits({ 528: filePath: 'temp', 529: fileContents: originalContent, 530: edits: edits2, 531: }) 532: } catch (e) { 533: error2 = errorMessage(e) 534: } 535: if (error1 !== null && error2 !== null) { 536: return error1 === error2 537: } 538: if (error1 !== null || error2 !== null) { 539: return false 540: } 541: return result1!.updatedFile === result2!.updatedFile 542: } 543: export function areFileEditsInputsEquivalent( 544: input1: { 545: file_path: string 546: edits: FileEdit[] 547: }, 548: input2: { 549: file_path: string 550: edits: FileEdit[] 551: }, 552: ): boolean { 553: if (input1.file_path !== input2.file_path) { 554: return false 555: } 556: if ( 557: input1.edits.length === input2.edits.length && 558: input1.edits.every((edit1, index) => { 559: const edit2 = input2.edits[index] 560: return ( 561: edit2 !== undefined && 562: edit1.old_string === edit2.old_string && 563: edit1.new_string === edit2.new_string && 564: edit1.replace_all === edit2.replace_all 565: ) 566: }) 567: ) { 568: return true 569: } 570: let fileContent = '' 571: try { 572: fileContent = readFileSyncCached(input1.file_path) 573: } catch (error) { 574: if (!isENOENT(error)) { 575: throw error 576: } 577: } 578: return areFileEditsEquivalent(input1.edits, input2.edits, fileContent) 579: }

File: src/tools/FileReadTool/FileReadTool.ts

typescript 1: import type { Base64ImageSource } from '@anthropic-ai/sdk/resources/index.mjs' 2: import { readdir, readFile as readFileAsync } from 'fs/promises' 3: import * as path from 'path' 4: import { posix, win32 } from 'path' 5: import { z } from 'zod/v4' 6: import { 7: PDF_AT_MENTION_INLINE_THRESHOLD, 8: PDF_EXTRACT_SIZE_THRESHOLD, 9: PDF_MAX_PAGES_PER_READ, 10: } from '../../constants/apiLimits.js' 11: import { hasBinaryExtension } from '../../constants/files.js' 12: import { memoryFreshnessNote } from '../../memdir/memoryAge.js' 13: import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js' 14: import { logEvent } from '../../services/analytics/index.js' 15: import { 16: type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 17: getFileExtensionForAnalytics, 18: } from '../../services/analytics/metadata.js' 19: import { 20: countTokensWithAPI, 21: roughTokenCountEstimationForFileType, 22: } from '../../services/tokenEstimation.js' 23: import { 24: activateConditionalSkillsForPaths, 25: addSkillDirectories, 26: discoverSkillDirsForPaths, 27: } from '../../skills/loadSkillsDir.js' 28: import type { ToolUseContext } from '../../Tool.js' 29: import { buildTool, type ToolDef } from '../../Tool.js' 30: import { getCwd } from '../../utils/cwd.js' 31: import { getClaudeConfigHomeDir, isEnvTruthy } from '../../utils/envUtils.js' 32: import { getErrnoCode, isENOENT } from '../../utils/errors.js' 33: import { 34: addLineNumbers, 35: FILE_NOT_FOUND_CWD_NOTE, 36: findSimilarFile, 37: getFileModificationTimeAsync, 38: suggestPathUnderCwd, 39: } from '../../utils/file.js' 40: import { logFileOperation } from '../../utils/fileOperationAnalytics.js' 41: import { formatFileSize } from '../../utils/format.js' 42: import { getFsImplementation } from '../../utils/fsOperations.js' 43: import { 44: compressImageBufferWithTokenLimit, 45: createImageMetadataText, 46: detectImageFormatFromBuffer, 47: type ImageDimensions, 48: ImageResizeError, 49: maybeResizeAndDownsampleImageBuffer, 50: } from '../../utils/imageResizer.js' 51: import { lazySchema } from '../../utils/lazySchema.js' 52: import { logError } from '../../utils/log.js' 53: import { isAutoMemFile } from '../../utils/memoryFileDetection.js' 54: import { createUserMessage } from '../../utils/messages.js' 55: import { getCanonicalName, getMainLoopModel } from '../../utils/model/model.js' 56: import { 57: mapNotebookCellsToToolResult, 58: readNotebook, 59: } from '../../utils/notebook.js' 60: import { expandPath } from '../../utils/path.js' 61: import { extractPDFPages, getPDFPageCount, readPDF } from '../../utils/pdf.js' 62: import { 63: isPDFExtension, 64: isPDFSupported, 65: parsePDFPageRange, 66: } from '../../utils/pdfUtils.js' 67: import { 68: checkReadPermissionForTool, 69: matchingRuleForInput, 70: } from '../../utils/permissions/filesystem.js' 71: import type { PermissionDecision } from '../../utils/permissions/PermissionResult.js' 72: import { matchWildcardPattern } from '../../utils/permissions/shellRuleMatching.js' 73: import { readFileInRange } from '../../utils/readFileInRange.js' 74: import { semanticNumber } from '../../utils/semanticNumber.js' 75: import { jsonStringify } from '../../utils/slowOperations.js' 76: import { BASH_TOOL_NAME } from '../BashTool/toolName.js' 77: import { getDefaultFileReadingLimits } from './limits.js' 78: import { 79: DESCRIPTION, 80: FILE_READ_TOOL_NAME, 81: FILE_UNCHANGED_STUB, 82: LINE_FORMAT_INSTRUCTION, 83: OFFSET_INSTRUCTION_DEFAULT, 84: OFFSET_INSTRUCTION_TARGETED, 85: renderPromptTemplate, 86: } from './prompt.js' 87: import { 88: getToolUseSummary, 89: renderToolResultMessage, 90: renderToolUseErrorMessage, 91: renderToolUseMessage, 92: renderToolUseTag, 93: userFacingName, 94: } from './UI.js' 95: const BLOCKED_DEVICE_PATHS = new Set([ 96: '/dev/zero', 97: '/dev/random', 98: '/dev/urandom', 99: '/dev/full', 100: '/dev/stdin', 101: '/dev/tty', 102: '/dev/console', 103: '/dev/stdout', 104: '/dev/stderr', 105: '/dev/fd/0', 106: '/dev/fd/1', 107: '/dev/fd/2', 108: ]) 109: function isBlockedDevicePath(filePath: string): boolean { 110: if (BLOCKED_DEVICE_PATHS.has(filePath)) return true 111: if ( 112: filePath.startsWith('/proc/') && 113: (filePath.endsWith('/fd/0') || 114: filePath.endsWith('/fd/1') || 115: filePath.endsWith('/fd/2')) 116: ) 117: return true 118: return false 119: } 120: const THIN_SPACE = String.fromCharCode(8239) 121: function getAlternateScreenshotPath(filePath: string): string | undefined { 122: const filename = path.basename(filePath) 123: const amPmPattern = /^(.+)([ \u202F])(AM|PM)(\.png)$/ 124: const match = filename.match(amPmPattern) 125: if (!match) return undefined 126: const currentSpace = match[2] 127: const alternateSpace = currentSpace === ' ' ? THIN_SPACE : ' ' 128: return filePath.replace( 129: `${currentSpace}${match[3]}${match[4]}`, 130: `${alternateSpace}${match[3]}${match[4]}`, 131: ) 132: } 133: type FileReadListener = (filePath: string, content: string) => void 134: const fileReadListeners: FileReadListener[] = [] 135: export function registerFileReadListener( 136: listener: FileReadListener, 137: ): () => void { 138: fileReadListeners.push(listener) 139: return () => { 140: const i = fileReadListeners.indexOf(listener) 141: if (i >= 0) fileReadListeners.splice(i, 1) 142: } 143: } 144: export class MaxFileReadTokenExceededError extends Error { 145: constructor( 146: public tokenCount: number, 147: public maxTokens: number, 148: ) { 149: super( 150: `File content (${tokenCount} tokens) exceeds maximum allowed tokens (${maxTokens}). Use offset and limit parameters to read specific portions of the file, or search for specific content instead of reading the whole file.`, 151: ) 152: this.name = 'MaxFileReadTokenExceededError' 153: } 154: } 155: const IMAGE_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp']) 156: function detectSessionFileType( 157: filePath: string, 158: ): 'session_memory' | 'session_transcript' | null { 159: const configDir = getClaudeConfigHomeDir() 160: if (!filePath.startsWith(configDir)) { 161: return null 162: } 163: const normalizedPath = filePath.split(win32.sep).join(posix.sep) 164: if ( 165: normalizedPath.includes('/session-memory/') && 166: normalizedPath.endsWith('.md') 167: ) { 168: return 'session_memory' 169: } 170: if ( 171: normalizedPath.includes('/projects/') && 172: normalizedPath.endsWith('.jsonl') 173: ) { 174: return 'session_transcript' 175: } 176: return null 177: } 178: const inputSchema = lazySchema(() => 179: z.strictObject({ 180: file_path: z.string().describe('The absolute path to the file to read'), 181: offset: semanticNumber(z.number().int().nonnegative().optional()).describe( 182: 'The line number to start reading from. Only provide if the file is too large to read at once', 183: ), 184: limit: semanticNumber(z.number().int().positive().optional()).describe( 185: 'The number of lines to read. Only provide if the file is too large to read at once.', 186: ), 187: pages: z 188: .string() 189: .optional() 190: .describe( 191: `Page range for PDF files (e.g., "1-5", "3", "10-20"). Only applicable to PDF files. Maximum ${PDF_MAX_PAGES_PER_READ} pages per request.`, 192: ), 193: }), 194: ) 195: type InputSchema = ReturnType<typeof inputSchema> 196: export type Input = z.infer<InputSchema> 197: const outputSchema = lazySchema(() => { 198: const imageMediaTypes = z.enum([ 199: 'image/jpeg', 200: 'image/png', 201: 'image/gif', 202: 'image/webp', 203: ]) 204: return z.discriminatedUnion('type', [ 205: z.object({ 206: type: z.literal('text'), 207: file: z.object({ 208: filePath: z.string().describe('The path to the file that was read'), 209: content: z.string().describe('The content of the file'), 210: numLines: z 211: .number() 212: .describe('Number of lines in the returned content'), 213: startLine: z.number().describe('The starting line number'), 214: totalLines: z.number().describe('Total number of lines in the file'), 215: }), 216: }), 217: z.object({ 218: type: z.literal('image'), 219: file: z.object({ 220: base64: z.string().describe('Base64-encoded image data'), 221: type: imageMediaTypes.describe('The MIME type of the image'), 222: originalSize: z.number().describe('Original file size in bytes'), 223: dimensions: z 224: .object({ 225: originalWidth: z 226: .number() 227: .optional() 228: .describe('Original image width in pixels'), 229: originalHeight: z 230: .number() 231: .optional() 232: .describe('Original image height in pixels'), 233: displayWidth: z 234: .number() 235: .optional() 236: .describe('Displayed image width in pixels (after resizing)'), 237: displayHeight: z 238: .number() 239: .optional() 240: .describe('Displayed image height in pixels (after resizing)'), 241: }) 242: .optional() 243: .describe('Image dimension info for coordinate mapping'), 244: }), 245: }), 246: z.object({ 247: type: z.literal('notebook'), 248: file: z.object({ 249: filePath: z.string().describe('The path to the notebook file'), 250: cells: z.array(z.any()).describe('Array of notebook cells'), 251: }), 252: }), 253: z.object({ 254: type: z.literal('pdf'), 255: file: z.object({ 256: filePath: z.string().describe('The path to the PDF file'), 257: base64: z.string().describe('Base64-encoded PDF data'), 258: originalSize: z.number().describe('Original file size in bytes'), 259: }), 260: }), 261: z.object({ 262: type: z.literal('parts'), 263: file: z.object({ 264: filePath: z.string().describe('The path to the PDF file'), 265: originalSize: z.number().describe('Original file size in bytes'), 266: count: z.number().describe('Number of pages extracted'), 267: outputDir: z 268: .string() 269: .describe('Directory containing extracted page images'), 270: }), 271: }), 272: z.object({ 273: type: z.literal('file_unchanged'), 274: file: z.object({ 275: filePath: z.string().describe('The path to the file'), 276: }), 277: }), 278: ]) 279: }) 280: type OutputSchema = ReturnType<typeof outputSchema> 281: export type Output = z.infer<OutputSchema> 282: export const FileReadTool = buildTool({ 283: name: FILE_READ_TOOL_NAME, 284: searchHint: 'read files, images, PDFs, notebooks', 285: maxResultSizeChars: Infinity, 286: strict: true, 287: async description() { 288: return DESCRIPTION 289: }, 290: async prompt() { 291: const limits = getDefaultFileReadingLimits() 292: const maxSizeInstruction = limits.includeMaxSizeInPrompt 293: ? `. Files larger than ${formatFileSize(limits.maxSizeBytes)} will return an error; use offset and limit for larger files` 294: : '' 295: const offsetInstruction = limits.targetedRangeNudge 296: ? OFFSET_INSTRUCTION_TARGETED 297: : OFFSET_INSTRUCTION_DEFAULT 298: return renderPromptTemplate( 299: pickLineFormatInstruction(), 300: maxSizeInstruction, 301: offsetInstruction, 302: ) 303: }, 304: get inputSchema(): InputSchema { 305: return inputSchema() 306: }, 307: get outputSchema(): OutputSchema { 308: return outputSchema() 309: }, 310: userFacingName, 311: getToolUseSummary, 312: getActivityDescription(input) { 313: const summary = getToolUseSummary(input) 314: return summary ? `Reading ${summary}` : 'Reading file' 315: }, 316: isConcurrencySafe() { 317: return true 318: }, 319: isReadOnly() { 320: return true 321: }, 322: toAutoClassifierInput(input) { 323: return input.file_path 324: }, 325: isSearchOrReadCommand() { 326: return { isSearch: false, isRead: true } 327: }, 328: getPath({ file_path }): string { 329: return file_path || getCwd() 330: }, 331: backfillObservableInput(input) { 332: if (typeof input.file_path === 'string') { 333: input.file_path = expandPath(input.file_path) 334: } 335: }, 336: async preparePermissionMatcher({ file_path }) { 337: return pattern => matchWildcardPattern(pattern, file_path) 338: }, 339: async checkPermissions(input, context): Promise<PermissionDecision> { 340: const appState = context.getAppState() 341: return checkReadPermissionForTool( 342: FileReadTool, 343: input, 344: appState.toolPermissionContext, 345: ) 346: }, 347: renderToolUseMessage, 348: renderToolUseTag, 349: renderToolResultMessage, 350: extractSearchText() { 351: return '' 352: }, 353: renderToolUseErrorMessage, 354: async validateInput({ file_path, pages }, toolUseContext: ToolUseContext) { 355: // Validate pages parameter (pure string parsing, no I/O) 356: if (pages !== undefined) { 357: const parsed = parsePDFPageRange(pages) 358: if (!parsed) { 359: return { 360: result: false, 361: message: `Invalid pages parameter: "${pages}". Use formats like "1-5", "3", or "10-20". Pages are 1-indexed.`, 362: errorCode: 7, 363: } 364: } 365: const rangeSize = 366: parsed.lastPage === Infinity 367: ? PDF_MAX_PAGES_PER_READ + 1 368: : parsed.lastPage - parsed.firstPage + 1 369: if (rangeSize > PDF_MAX_PAGES_PER_READ) { 370: return { 371: result: false, 372: message: `Page range "${pages}" exceeds maximum of ${PDF_MAX_PAGES_PER_READ} pages per request. Please use a smaller range.`, 373: errorCode: 8, 374: } 375: } 376: } 377: // Path expansion + deny rule check (no I/O) 378: const fullFilePath = expandPath(file_path) 379: const appState = toolUseContext.getAppState() 380: const denyRule = matchingRuleForInput( 381: fullFilePath, 382: appState.toolPermissionContext, 383: 'read', 384: 'deny', 385: ) 386: if (denyRule !== null) { 387: return { 388: result: false, 389: message: 390: 'File is in a directory that is denied by your permission settings.', 391: errorCode: 1, 392: } 393: } 394: const isUncPath = 395: fullFilePath.startsWith('\\\\') || fullFilePath.startsWith(' 396: if (isUncPath) { 397: return { result: true } 398: } 399: const ext = path.extname(fullFilePath).toLowerCase() 400: if ( 401: hasBinaryExtension(fullFilePath) && 402: !isPDFExtension(ext) && 403: !IMAGE_EXTENSIONS.has(ext.slice(1)) 404: ) { 405: return { 406: result: false, 407: message: `This tool cannot read binary files. The file appears to be a binary ${ext} file. Please use appropriate tools for binary file analysis.`, 408: errorCode: 4, 409: } 410: } 411: if (isBlockedDevicePath(fullFilePath)) { 412: return { 413: result: false, 414: message: `Cannot read '${file_path}': this device file would block or produce infinite output.`, 415: errorCode: 9, 416: } 417: } 418: return { result: true } 419: }, 420: async call( 421: { file_path, offset = 1, limit = undefined, pages }, 422: context, 423: _canUseTool?, 424: parentMessage?, 425: ) { 426: const { readFileState, fileReadingLimits } = context 427: const defaults = getDefaultFileReadingLimits() 428: const maxSizeBytes = 429: fileReadingLimits?.maxSizeBytes ?? defaults.maxSizeBytes 430: const maxTokens = fileReadingLimits?.maxTokens ?? defaults.maxTokens 431: if (fileReadingLimits !== undefined) { 432: logEvent('tengu_file_read_limits_override', { 433: hasMaxTokens: fileReadingLimits.maxTokens !== undefined, 434: hasMaxSizeBytes: fileReadingLimits.maxSizeBytes !== undefined, 435: }) 436: } 437: const ext = path.extname(file_path).toLowerCase().slice(1) 438: const fullFilePath = expandPath(file_path) 439: const dedupKillswitch = getFeatureValue_CACHED_MAY_BE_STALE( 440: 'tengu_read_dedup_killswitch', 441: false, 442: ) 443: const existingState = dedupKillswitch 444: ? undefined 445: : readFileState.get(fullFilePath) 446: if ( 447: existingState && 448: !existingState.isPartialView && 449: existingState.offset !== undefined 450: ) { 451: const rangeMatch = 452: existingState.offset === offset && existingState.limit === limit 453: if (rangeMatch) { 454: try { 455: const mtimeMs = await getFileModificationTimeAsync(fullFilePath) 456: if (mtimeMs === existingState.timestamp) { 457: const analyticsExt = getFileExtensionForAnalytics(fullFilePath) 458: logEvent('tengu_file_read_dedup', { 459: ...(analyticsExt !== undefined && { ext: analyticsExt }), 460: }) 461: return { 462: data: { 463: type: 'file_unchanged' as const, 464: file: { filePath: file_path }, 465: }, 466: } 467: } 468: } catch { 469: } 470: } 471: } 472: const cwd = getCwd() 473: if (!isEnvTruthy(process.env.CLAUDE_CODE_SIMPLE)) { 474: const newSkillDirs = await discoverSkillDirsForPaths([fullFilePath], cwd) 475: if (newSkillDirs.length > 0) { 476: for (const dir of newSkillDirs) { 477: context.dynamicSkillDirTriggers?.add(dir) 478: } 479: addSkillDirectories(newSkillDirs).catch(() => {}) 480: } 481: activateConditionalSkillsForPaths([fullFilePath], cwd) 482: } 483: try { 484: return await callInner( 485: file_path, 486: fullFilePath, 487: fullFilePath, 488: ext, 489: offset, 490: limit, 491: pages, 492: maxSizeBytes, 493: maxTokens, 494: readFileState, 495: context, 496: parentMessage?.message.id, 497: ) 498: } catch (error) { 499: const code = getErrnoCode(error) 500: if (code === 'ENOENT') { 501: const altPath = getAlternateScreenshotPath(fullFilePath) 502: if (altPath) { 503: try { 504: return await callInner( 505: file_path, 506: fullFilePath, 507: altPath, 508: ext, 509: offset, 510: limit, 511: pages, 512: maxSizeBytes, 513: maxTokens, 514: readFileState, 515: context, 516: parentMessage?.message.id, 517: ) 518: } catch (altError) { 519: if (!isENOENT(altError)) { 520: throw altError 521: } 522: } 523: } 524: const similarFilename = findSimilarFile(fullFilePath) 525: const cwdSuggestion = await suggestPathUnderCwd(fullFilePath) 526: let message = `File does not exist. ${FILE_NOT_FOUND_CWD_NOTE} ${getCwd()}.` 527: if (cwdSuggestion) { 528: message += ` Did you mean ${cwdSuggestion}?` 529: } else if (similarFilename) { 530: message += ` Did you mean ${similarFilename}?` 531: } 532: throw new Error(message) 533: } 534: throw error 535: } 536: }, 537: mapToolResultToToolResultBlockParam(data, toolUseID) { 538: switch (data.type) { 539: case 'image': { 540: return { 541: tool_use_id: toolUseID, 542: type: 'tool_result', 543: content: [ 544: { 545: type: 'image', 546: source: { 547: type: 'base64', 548: data: data.file.base64, 549: media_type: data.file.type, 550: }, 551: }, 552: ], 553: } 554: } 555: case 'notebook': 556: return mapNotebookCellsToToolResult(data.file.cells, toolUseID) 557: case 'pdf': 558: return { 559: tool_use_id: toolUseID, 560: type: 'tool_result', 561: content: `PDF file read: ${data.file.filePath} (${formatFileSize(data.file.originalSize)})`, 562: } 563: case 'parts': 564: return { 565: tool_use_id: toolUseID, 566: type: 'tool_result', 567: content: `PDF pages extracted: ${data.file.count} page(s) from ${data.file.filePath} (${formatFileSize(data.file.originalSize)})`, 568: } 569: case 'file_unchanged': 570: return { 571: tool_use_id: toolUseID, 572: type: 'tool_result', 573: content: FILE_UNCHANGED_STUB, 574: } 575: case 'text': { 576: let content: string 577: if (data.file.content) { 578: content = 579: memoryFileFreshnessPrefix(data) + 580: formatFileLines(data.file) + 581: (shouldIncludeFileReadMitigation() 582: ? CYBER_RISK_MITIGATION_REMINDER 583: : '') 584: } else { 585: // Determine the appropriate warning message 586: content = 587: data.file.totalLines === 0 588: ? '<system-reminder>Warning: the file exists but the contents are empty.</system-reminder>' 589: : `<system-reminder>Warning: the file exists but is shorter than the provided offset (${data.file.startLine}). The file has ${data.file.totalLines} lines.</system-reminder>` 590: } 591: return { 592: tool_use_id: toolUseID, 593: type: 'tool_result', 594: content, 595: } 596: } 597: } 598: }, 599: } satisfies ToolDef<InputSchema, Output>) 600: function pickLineFormatInstruction(): string { 601: return LINE_FORMAT_INSTRUCTION 602: } 603: function formatFileLines(file: { content: string; startLine: number }): string { 604: return addLineNumbers(file) 605: } 606: export const CYBER_RISK_MITIGATION_REMINDER = 607: '\n\n<system-reminder>\nWhenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.\n</system-reminder>\n' 608: const MITIGATION_EXEMPT_MODELS = new Set(['claude-opus-4-6']) 609: function shouldIncludeFileReadMitigation(): boolean { 610: const shortName = getCanonicalName(getMainLoopModel()) 611: return !MITIGATION_EXEMPT_MODELS.has(shortName) 612: } 613: const memoryFileMtimes = new WeakMap<object, number>() 614: function memoryFileFreshnessPrefix(data: object): string { 615: const mtimeMs = memoryFileMtimes.get(data) 616: if (mtimeMs === undefined) return '' 617: return memoryFreshnessNote(mtimeMs) 618: } 619: async function validateContentTokens( 620: content: string, 621: ext: string, 622: maxTokens?: number, 623: ): Promise<void> { 624: const effectiveMaxTokens = 625: maxTokens ?? getDefaultFileReadingLimits().maxTokens 626: const tokenEstimate = roughTokenCountEstimationForFileType(content, ext) 627: if (!tokenEstimate || tokenEstimate <= effectiveMaxTokens / 4) return 628: const tokenCount = await countTokensWithAPI(content) 629: const effectiveCount = tokenCount ?? tokenEstimate 630: if (effectiveCount > effectiveMaxTokens) { 631: throw new MaxFileReadTokenExceededError(effectiveCount, effectiveMaxTokens) 632: } 633: } 634: type ImageResult = { 635: type: 'image' 636: file: { 637: base64: string 638: type: Base64ImageSource['media_type'] 639: originalSize: number 640: dimensions?: ImageDimensions 641: } 642: } 643: function createImageResponse( 644: buffer: Buffer, 645: mediaType: string, 646: originalSize: number, 647: dimensions?: ImageDimensions, 648: ): ImageResult { 649: return { 650: type: 'image', 651: file: { 652: base64: buffer.toString('base64'), 653: type: `image/${mediaType}` as Base64ImageSource['media_type'], 654: originalSize, 655: dimensions, 656: }, 657: } 658: } 659: async function callInner( 660: file_path: string, 661: fullFilePath: string, 662: resolvedFilePath: string, 663: ext: string, 664: offset: number, 665: limit: number | undefined, 666: pages: string | undefined, 667: maxSizeBytes: number, 668: maxTokens: number, 669: readFileState: ToolUseContext['readFileState'], 670: context: ToolUseContext, 671: messageId: string | undefined, 672: ): Promise<{ 673: data: Output 674: newMessages?: ReturnType<typeof createUserMessage>[] 675: }> { 676: if (ext === 'ipynb') { 677: const cells = await readNotebook(resolvedFilePath) 678: const cellsJson = jsonStringify(cells) 679: const cellsJsonBytes = Buffer.byteLength(cellsJson) 680: if (cellsJsonBytes > maxSizeBytes) { 681: throw new Error( 682: `Notebook content (${formatFileSize(cellsJsonBytes)}) exceeds maximum allowed size (${formatFileSize(maxSizeBytes)}). ` + 683: `Use ${BASH_TOOL_NAME} with jq to read specific portions:\n` + 684: ` cat "${file_path}" | jq '.cells[:20]' # First 20 cells\n` + 685: ` cat "${file_path}" | jq '.cells[100:120]' # Cells 100-120\n` + 686: ` cat "${file_path}" | jq '.cells | length' # Count total cells\n` + 687: ` cat "${file_path}" | jq '.cells[] | select(.cell_type=="code") | .source' # All code sources`, 688: ) 689: } 690: await validateContentTokens(cellsJson, ext, maxTokens) 691: const stats = await getFsImplementation().stat(resolvedFilePath) 692: readFileState.set(fullFilePath, { 693: content: cellsJson, 694: timestamp: Math.floor(stats.mtimeMs), 695: offset, 696: limit, 697: }) 698: context.nestedMemoryAttachmentTriggers?.add(fullFilePath) 699: const data = { 700: type: 'notebook' as const, 701: file: { filePath: file_path, cells }, 702: } 703: logFileOperation({ 704: operation: 'read', 705: tool: 'FileReadTool', 706: filePath: fullFilePath, 707: content: cellsJson, 708: }) 709: return { data } 710: } 711: if (IMAGE_EXTENSIONS.has(ext)) { 712: const data = await readImageWithTokenBudget(resolvedFilePath, maxTokens) 713: context.nestedMemoryAttachmentTriggers?.add(fullFilePath) 714: logFileOperation({ 715: operation: 'read', 716: tool: 'FileReadTool', 717: filePath: fullFilePath, 718: content: data.file.base64, 719: }) 720: const metadataText = data.file.dimensions 721: ? createImageMetadataText(data.file.dimensions) 722: : null 723: return { 724: data, 725: ...(metadataText && { 726: newMessages: [ 727: createUserMessage({ content: metadataText, isMeta: true }), 728: ], 729: }), 730: } 731: } 732: if (isPDFExtension(ext)) { 733: if (pages) { 734: const parsedRange = parsePDFPageRange(pages) 735: const extractResult = await extractPDFPages( 736: resolvedFilePath, 737: parsedRange ?? undefined, 738: ) 739: if (!extractResult.success) { 740: throw new Error(extractResult.error.message) 741: } 742: logEvent('tengu_pdf_page_extraction', { 743: success: true, 744: pageCount: extractResult.data.file.count, 745: fileSize: extractResult.data.file.originalSize, 746: hasPageRange: true, 747: }) 748: logFileOperation({ 749: operation: 'read', 750: tool: 'FileReadTool', 751: filePath: fullFilePath, 752: content: `PDF pages ${pages}`, 753: }) 754: const entries = await readdir(extractResult.data.file.outputDir) 755: const imageFiles = entries.filter(f => f.endsWith('.jpg')).sort() 756: const imageBlocks = await Promise.all( 757: imageFiles.map(async f => { 758: const imgPath = path.join(extractResult.data.file.outputDir, f) 759: const imgBuffer = await readFileAsync(imgPath) 760: const resized = await maybeResizeAndDownsampleImageBuffer( 761: imgBuffer, 762: imgBuffer.length, 763: 'jpeg', 764: ) 765: return { 766: type: 'image' as const, 767: source: { 768: type: 'base64' as const, 769: media_type: 770: `image/${resized.mediaType}` as Base64ImageSource['media_type'], 771: data: resized.buffer.toString('base64'), 772: }, 773: } 774: }), 775: ) 776: return { 777: data: extractResult.data, 778: ...(imageBlocks.length > 0 && { 779: newMessages: [ 780: createUserMessage({ content: imageBlocks, isMeta: true }), 781: ], 782: }), 783: } 784: } 785: const pageCount = await getPDFPageCount(resolvedFilePath) 786: if (pageCount !== null && pageCount > PDF_AT_MENTION_INLINE_THRESHOLD) { 787: throw new Error( 788: `This PDF has ${pageCount} pages, which is too many to read at once. ` + 789: `Use the pages parameter to read specific page ranges (e.g., pages: "1-5"). ` + 790: `Maximum ${PDF_MAX_PAGES_PER_READ} pages per request.`, 791: ) 792: } 793: const fs = getFsImplementation() 794: const stats = await fs.stat(resolvedFilePath) 795: const shouldExtractPages = 796: !isPDFSupported() || stats.size > PDF_EXTRACT_SIZE_THRESHOLD 797: if (shouldExtractPages) { 798: const extractResult = await extractPDFPages(resolvedFilePath) 799: if (extractResult.success) { 800: logEvent('tengu_pdf_page_extraction', { 801: success: true, 802: pageCount: extractResult.data.file.count, 803: fileSize: extractResult.data.file.originalSize, 804: }) 805: } else { 806: logEvent('tengu_pdf_page_extraction', { 807: success: false, 808: available: extractResult.error.reason !== 'unavailable', 809: fileSize: stats.size, 810: }) 811: } 812: } 813: if (!isPDFSupported()) { 814: throw new Error( 815: 'Reading full PDFs is not supported with this model. Use a newer model (Sonnet 3.5 v2 or later), ' + 816: `or use the pages parameter to read specific page ranges (e.g., pages: "1-5", maximum ${PDF_MAX_PAGES_PER_READ} pages per request). ` + 817: 'Page extraction requires poppler-utils: install with `brew install poppler` on macOS or `apt-get install poppler-utils` on Debian/Ubuntu.', 818: ) 819: } 820: const readResult = await readPDF(resolvedFilePath) 821: if (!readResult.success) { 822: throw new Error(readResult.error.message) 823: } 824: const pdfData = readResult.data 825: logFileOperation({ 826: operation: 'read', 827: tool: 'FileReadTool', 828: filePath: fullFilePath, 829: content: pdfData.file.base64, 830: }) 831: return { 832: data: pdfData, 833: newMessages: [ 834: createUserMessage({ 835: content: [ 836: { 837: type: 'document', 838: source: { 839: type: 'base64', 840: media_type: 'application/pdf', 841: data: pdfData.file.base64, 842: }, 843: }, 844: ], 845: isMeta: true, 846: }), 847: ], 848: } 849: } 850: const lineOffset = offset === 0 ? 0 : offset - 1 851: const { content, lineCount, totalLines, totalBytes, readBytes, mtimeMs } = 852: await readFileInRange( 853: resolvedFilePath, 854: lineOffset, 855: limit, 856: limit === undefined ? maxSizeBytes : undefined, 857: context.abortController.signal, 858: ) 859: await validateContentTokens(content, ext, maxTokens) 860: readFileState.set(fullFilePath, { 861: content, 862: timestamp: Math.floor(mtimeMs), 863: offset, 864: limit, 865: }) 866: context.nestedMemoryAttachmentTriggers?.add(fullFilePath) 867: for (const listener of fileReadListeners.slice()) { 868: listener(resolvedFilePath, content) 869: } 870: const data = { 871: type: 'text' as const, 872: file: { 873: filePath: file_path, 874: content, 875: numLines: lineCount, 876: startLine: offset, 877: totalLines, 878: }, 879: } 880: if (isAutoMemFile(fullFilePath)) { 881: memoryFileMtimes.set(data, mtimeMs) 882: } 883: logFileOperation({ 884: operation: 'read', 885: tool: 'FileReadTool', 886: filePath: fullFilePath, 887: content, 888: }) 889: const sessionFileType = detectSessionFileType(fullFilePath) 890: const analyticsExt = getFileExtensionForAnalytics(fullFilePath) 891: logEvent('tengu_session_file_read', { 892: totalLines, 893: readLines: lineCount, 894: totalBytes, 895: readBytes, 896: offset, 897: ...(limit !== undefined && { limit }), 898: ...(analyticsExt !== undefined && { ext: analyticsExt }), 899: ...(messageId !== undefined && { 900: messageID: 901: messageId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 902: }), 903: is_session_memory: sessionFileType === 'session_memory', 904: is_session_transcript: sessionFileType === 'session_transcript', 905: }) 906: return { data } 907: } 908: export async function readImageWithTokenBudget( 909: filePath: string, 910: maxTokens: number = getDefaultFileReadingLimits().maxTokens, 911: maxBytes?: number, 912: ): Promise<ImageResult> { 913: const imageBuffer = await getFsImplementation().readFileBytes( 914: filePath, 915: maxBytes, 916: ) 917: const originalSize = imageBuffer.length 918: if (originalSize === 0) { 919: throw new Error(`Image file is empty: ${filePath}`) 920: } 921: const detectedMediaType = detectImageFormatFromBuffer(imageBuffer) 922: const detectedFormat = detectedMediaType.split('/')[1] || 'png' 923: let result: ImageResult 924: try { 925: const resized = await maybeResizeAndDownsampleImageBuffer( 926: imageBuffer, 927: originalSize, 928: detectedFormat, 929: ) 930: result = createImageResponse( 931: resized.buffer, 932: resized.mediaType, 933: originalSize, 934: resized.dimensions, 935: ) 936: } catch (e) { 937: if (e instanceof ImageResizeError) throw e 938: logError(e) 939: result = createImageResponse(imageBuffer, detectedFormat, originalSize) 940: } 941: const estimatedTokens = Math.ceil(result.file.base64.length * 0.125) 942: if (estimatedTokens > maxTokens) { 943: try { 944: const compressed = await compressImageBufferWithTokenLimit( 945: imageBuffer, 946: maxTokens, 947: detectedMediaType, 948: ) 949: return { 950: type: 'image', 951: file: { 952: base64: compressed.base64, 953: type: compressed.mediaType, 954: originalSize, 955: }, 956: } 957: } catch (e) { 958: logError(e) 959: try { 960: const sharpModule = await import('sharp') 961: const sharp = 962: ( 963: sharpModule as { 964: default?: typeof sharpModule 965: } & typeof sharpModule 966: ).default || sharpModule 967: const fallbackBuffer = await sharp(imageBuffer) 968: .resize(400, 400, { 969: fit: 'inside', 970: withoutEnlargement: true, 971: }) 972: .jpeg({ quality: 20 }) 973: .toBuffer() 974: return createImageResponse(fallbackBuffer, 'jpeg', originalSize) 975: } catch (error) { 976: logError(error) 977: return createImageResponse(imageBuffer, detectedFormat, originalSize) 978: } 979: } 980: } 981: return result 982: }

File: src/tools/FileReadTool/imageProcessor.ts

typescript 1: import type { Buffer } from 'buffer' 2: import { isInBundledMode } from '../../utils/bundledMode.js' 3: export type SharpInstance = { 4: metadata(): Promise<{ width: number; height: number; format: string }> 5: resize( 6: width: number, 7: height: number, 8: options?: { fit?: string; withoutEnlargement?: boolean }, 9: ): SharpInstance 10: jpeg(options?: { quality?: number }): SharpInstance 11: png(options?: { 12: compressionLevel?: number 13: palette?: boolean 14: colors?: number 15: }): SharpInstance 16: webp(options?: { quality?: number }): SharpInstance 17: toBuffer(): Promise<Buffer> 18: } 19: export type SharpFunction = (input: Buffer) => SharpInstance 20: type SharpCreatorOptions = { 21: create: { 22: width: number 23: height: number 24: channels: 3 | 4 25: background: { r: number; g: number; b: number } 26: } 27: } 28: type SharpCreator = (options: SharpCreatorOptions) => SharpInstance 29: let imageProcessorModule: { default: SharpFunction } | null = null 30: let imageCreatorModule: { default: SharpCreator } | null = null 31: export async function getImageProcessor(): Promise<SharpFunction> { 32: if (imageProcessorModule) { 33: return imageProcessorModule.default 34: } 35: if (isInBundledMode()) { 36: try { 37: const imageProcessor = await import('image-processor-napi') 38: const sharp = imageProcessor.sharp || imageProcessor.default 39: imageProcessorModule = { default: sharp } 40: return sharp 41: } catch { 42: console.warn( 43: 'Native image processor not available, falling back to sharp', 44: ) 45: } 46: } 47: const imported = (await import( 48: 'sharp' 49: )) as unknown as MaybeDefault<SharpFunction> 50: const sharp = unwrapDefault(imported) 51: imageProcessorModule = { default: sharp } 52: return sharp 53: } 54: export async function getImageCreator(): Promise<SharpCreator> { 55: if (imageCreatorModule) { 56: return imageCreatorModule.default 57: } 58: const imported = (await import( 59: 'sharp' 60: )) as unknown as MaybeDefault<SharpCreator> 61: const sharp = unwrapDefault(imported) 62: imageCreatorModule = { default: sharp } 63: return sharp 64: } 65: type MaybeDefault<T> = T | { default: T } 66: function unwrapDefault<T extends (...args: never[]) => unknown>( 67: mod: MaybeDefault<T>, 68: ): T { 69: return typeof mod === 'function' ? mod : mod.default 70: }

File: src/tools/FileReadTool/limits.ts

typescript 1: import memoize from 'lodash-es/memoize.js' 2: import { getFeatureValue_CACHED_MAY_BE_STALE } from 'src/services/analytics/growthbook.js' 3: import { MAX_OUTPUT_SIZE } from 'src/utils/file.js' 4: export const DEFAULT_MAX_OUTPUT_TOKENS = 25000 5: function getEnvMaxTokens(): number | undefined { 6: const override = process.env.CLAUDE_CODE_FILE_READ_MAX_OUTPUT_TOKENS 7: if (override) { 8: const parsed = parseInt(override, 10) 9: if (!isNaN(parsed) && parsed > 0) { 10: return parsed 11: } 12: } 13: return undefined 14: } 15: export type FileReadingLimits = { 16: maxTokens: number 17: maxSizeBytes: number 18: includeMaxSizeInPrompt?: boolean 19: targetedRangeNudge?: boolean 20: } 21: export const getDefaultFileReadingLimits = memoize((): FileReadingLimits => { 22: const override = 23: getFeatureValue_CACHED_MAY_BE_STALE<Partial<FileReadingLimits> | null>( 24: 'tengu_amber_wren', 25: {}, 26: ) 27: const maxSizeBytes = 28: typeof override?.maxSizeBytes === 'number' && 29: Number.isFinite(override.maxSizeBytes) && 30: override.maxSizeBytes > 0 31: ? override.maxSizeBytes 32: : MAX_OUTPUT_SIZE 33: const envMaxTokens = getEnvMaxTokens() 34: const maxTokens = 35: envMaxTokens ?? 36: (typeof override?.maxTokens === 'number' && 37: Number.isFinite(override.maxTokens) && 38: override.maxTokens > 0 39: ? override.maxTokens 40: : DEFAULT_MAX_OUTPUT_TOKENS) 41: const includeMaxSizeInPrompt = 42: typeof override?.includeMaxSizeInPrompt === 'boolean' 43: ? override.includeMaxSizeInPrompt 44: : undefined 45: const targetedRangeNudge = 46: typeof override?.targetedRangeNudge === 'boolean' 47: ? override.targetedRangeNudge 48: : undefined 49: return { 50: maxSizeBytes, 51: maxTokens, 52: includeMaxSizeInPrompt, 53: targetedRangeNudge, 54: } 55: })

File: src/tools/FileReadTool/prompt.ts

typescript 1: import { isPDFSupported } from '../../utils/pdfUtils.js' 2: import { BASH_TOOL_NAME } from '../BashTool/toolName.js' 3: export const FILE_READ_TOOL_NAME = 'Read' 4: export const FILE_UNCHANGED_STUB = 5: 'File unchanged since last read. The content from the earlier Read tool_result in this conversation is still current — refer to that instead of re-reading.' 6: export const MAX_LINES_TO_READ = 2000 7: export const DESCRIPTION = 'Read a file from the local filesystem.' 8: export const LINE_FORMAT_INSTRUCTION = 9: '- Results are returned using cat -n format, with line numbers starting at 1' 10: export const OFFSET_INSTRUCTION_DEFAULT = 11: "- You can optionally specify a line offset and limit (especially handy for long files), but it's recommended to read the whole file by not providing these parameters" 12: export const OFFSET_INSTRUCTION_TARGETED = 13: '- When you already know which part of the file you need, only read that part. This can be important for larger files.' 14: export function renderPromptTemplate( 15: lineFormat: string, 16: maxSizeInstruction: string, 17: offsetInstruction: string, 18: ): string { 19: return `Reads a file from the local filesystem. You can access any file directly by using this tool. 20: Assume this tool is able to read all files on the machine. If the User provides a path to a file assume that path is valid. It is okay to read a file that does not exist; an error will be returned. 21: Usage: 22: - The file_path parameter must be an absolute path, not a relative path 23: - By default, it reads up to ${MAX_LINES_TO_READ} lines starting from the beginning of the file${maxSizeInstruction} 24: ${offsetInstruction} 25: ${lineFormat} 26: - This tool allows Claude Code to read images (eg PNG, JPG, etc). When reading an image file the contents are presented visually as Claude Code is a multimodal LLM.${ 27: isPDFSupported() 28: ? '\n- This tool can read PDF files (.pdf). For large PDFs (more than 10 pages), you MUST provide the pages parameter to read specific page ranges (e.g., pages: "1-5"). Reading a large PDF without the pages parameter will fail. Maximum 20 pages per request.' 29: : '' 30: } 31: - This tool can read Jupyter notebooks (.ipynb files) and returns all cells with their outputs, combining code, text, and visualizations. 32: - This tool can only read files, not directories. To read a directory, use an ls command via the ${BASH_TOOL_NAME} tool. 33: - You will regularly be asked to read screenshots. If the user provides a path to a screenshot, ALWAYS use this tool to view the file at the path. This tool will work with all temporary file paths. 34: - If you read a file that exists but has empty contents you will receive a system reminder warning in place of file contents.` 35: }

File: src/tools/FileReadTool/UI.tsx

typescript 1: import type { ToolResultBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'; 2: import * as React from 'react'; 3: import { extractTag } from 'src/utils/messages.js'; 4: import { FallbackToolUseErrorMessage } from '../../components/FallbackToolUseErrorMessage.js'; 5: import { FilePathLink } from '../../components/FilePathLink.js'; 6: import { MessageResponse } from '../../components/MessageResponse.js'; 7: import { Text } from '../../ink.js'; 8: import { FILE_NOT_FOUND_CWD_NOTE, getDisplayPath } from '../../utils/file.js'; 9: import { formatFileSize } from '../../utils/format.js'; 10: import { getPlansDirectory } from '../../utils/plans.js'; 11: import { getTaskOutputDir } from '../../utils/task/diskOutput.js'; 12: import type { Input, Output } from './FileReadTool.js'; 13: function getAgentOutputTaskId(filePath: string): string | null { 14: const prefix = `${getTaskOutputDir()}/`; 15: const suffix = '.output'; 16: if (filePath.startsWith(prefix) && filePath.endsWith(suffix)) { 17: const taskId = filePath.slice(prefix.length, -suffix.length); 18: if (taskId.length > 0 && taskId.length <= 20 && /^[a-zA-Z0-9_-]+$/.test(taskId)) { 19: return taskId; 20: } 21: } 22: return null; 23: } 24: export function renderToolUseMessage({ 25: file_path, 26: offset, 27: limit, 28: pages 29: }: Partial<Input>, { 30: verbose 31: }: { 32: verbose: boolean; 33: }): React.ReactNode { 34: if (!file_path) { 35: return null; 36: } 37: if (getAgentOutputTaskId(file_path)) { 38: return ''; 39: } 40: const displayPath = verbose ? file_path : getDisplayPath(file_path); 41: if (pages) { 42: return <> 43: <FilePathLink filePath={file_path}>{displayPath}</FilePathLink> 44: {` · pages ${pages}`} 45: </>; 46: } 47: if (verbose && (offset || limit)) { 48: const startLine = offset ?? 1; 49: const lineRange = limit ? `lines ${startLine}-${startLine + limit - 1}` : `from line ${startLine}`; 50: return <> 51: <FilePathLink filePath={file_path}>{displayPath}</FilePathLink> 52: {` · ${lineRange}`} 53: </>; 54: } 55: return <FilePathLink filePath={file_path}>{displayPath}</FilePathLink>; 56: } 57: export function renderToolUseTag({ 58: file_path 59: }: Partial<Input>): React.ReactNode { 60: const agentTaskId = file_path ? getAgentOutputTaskId(file_path) : null; 61: // Show agent task ID for Read tool when reading agent output 62: if (!agentTaskId) { 63: return null; 64: } 65: return <Text dimColor> {agentTaskId}</Text>; 66: } 67: export function renderToolResultMessage(output: Output): React.ReactNode { 68: // TODO: Render recursively 69: switch (output.type) { 70: case 'image': 71: { 72: const { 73: originalSize 74: } = output.file; 75: const formattedSize = formatFileSize(originalSize); 76: return <MessageResponse height={1}> 77: <Text>Read image ({formattedSize})</Text> 78: </MessageResponse>; 79: } 80: case 'notebook': 81: { 82: const { 83: cells 84: } = output.file; 85: if (!cells || cells.length < 1) { 86: return <Text color="error">No cells found in notebook</Text>; 87: } 88: return <MessageResponse height={1}> 89: <Text> 90: Read <Text bold>{cells.length}</Text> cells 91: </Text> 92: </MessageResponse>; 93: } 94: case 'pdf': 95: { 96: const { 97: originalSize 98: } = output.file; 99: const formattedSize = formatFileSize(originalSize); 100: return <MessageResponse height={1}> 101: <Text>Read PDF ({formattedSize})</Text> 102: </MessageResponse>; 103: } 104: case 'parts': 105: { 106: return <MessageResponse height={1}> 107: <Text> 108: Read <Text bold>{output.file.count}</Text>{' '} 109: {output.file.count === 1 ? 'page' : 'pages'} ( 110: {formatFileSize(output.file.originalSize)}) 111: </Text> 112: </MessageResponse>; 113: } 114: case 'text': 115: { 116: const { 117: numLines 118: } = output.file; 119: return <MessageResponse height={1}> 120: <Text> 121: Read <Text bold>{numLines}</Text>{' '} 122: {numLines === 1 ? 'line' : 'lines'} 123: </Text> 124: </MessageResponse>; 125: } 126: case 'file_unchanged': 127: { 128: return <MessageResponse height={1}> 129: <Text dimColor>Unchanged since last read</Text> 130: </MessageResponse>; 131: } 132: } 133: } 134: export function renderToolUseErrorMessage(result: ToolResultBlockParam['content'], { 135: verbose 136: }: { 137: verbose: boolean; 138: }): React.ReactNode { 139: if (!verbose && typeof result === 'string') { 140: if (result.includes(FILE_NOT_FOUND_CWD_NOTE)) { 141: return <MessageResponse> 142: <Text color="error">File not found</Text> 143: </MessageResponse>; 144: } 145: if (extractTag(result, 'tool_use_error')) { 146: return <MessageResponse> 147: <Text color="error">Error reading file</Text> 148: </MessageResponse>; 149: } 150: } 151: return <FallbackToolUseErrorMessage result={result} verbose={verbose} />; 152: } 153: export function userFacingName(input: Partial<Input> | undefined): string { 154: if (input?.file_path?.startsWith(getPlansDirectory())) { 155: return 'Reading Plan'; 156: } 157: if (input?.file_path && getAgentOutputTaskId(input.file_path)) { 158: return 'Read agent output'; 159: } 160: return 'Read'; 161: } 162: export function getToolUseSummary(input: Partial<Input> | undefined): string | null { 163: if (!input?.file_path) { 164: return null; 165: } 166: const agentTaskId = getAgentOutputTaskId(input.file_path); 167: if (agentTaskId) { 168: return agentTaskId; 169: } 170: return getDisplayPath(input.file_path); 171: }

File: src/tools/FileWriteTool/FileWriteTool.ts

typescript 1: import { dirname, sep } from 'path' 2: import { logEvent } from 'src/services/analytics/index.js' 3: import { z } from 'zod/v4' 4: import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js' 5: import { diagnosticTracker } from '../../services/diagnosticTracking.js' 6: import { clearDeliveredDiagnosticsForFile } from '../../services/lsp/LSPDiagnosticRegistry.js' 7: import { getLspServerManager } from '../../services/lsp/manager.js' 8: import { notifyVscodeFileUpdated } from '../../services/mcp/vscodeSdkMcp.js' 9: import { checkTeamMemSecrets } from '../../services/teamMemorySync/teamMemSecretGuard.js' 10: import { 11: activateConditionalSkillsForPaths, 12: addSkillDirectories, 13: discoverSkillDirsForPaths, 14: } from '../../skills/loadSkillsDir.js' 15: import type { ToolUseContext } from '../../Tool.js' 16: import { buildTool, type ToolDef } from '../../Tool.js' 17: import { getCwd } from '../../utils/cwd.js' 18: import { logForDebugging } from '../../utils/debug.js' 19: import { countLinesChanged, getPatchForDisplay } from '../../utils/diff.js' 20: import { isEnvTruthy } from '../../utils/envUtils.js' 21: import { isENOENT } from '../../utils/errors.js' 22: import { getFileModificationTime, writeTextContent } from '../../utils/file.js' 23: import { 24: fileHistoryEnabled, 25: fileHistoryTrackEdit, 26: } from '../../utils/fileHistory.js' 27: import { logFileOperation } from '../../utils/fileOperationAnalytics.js' 28: import { readFileSyncWithMetadata } from '../../utils/fileRead.js' 29: import { getFsImplementation } from '../../utils/fsOperations.js' 30: import { 31: fetchSingleFileGitDiff, 32: type ToolUseDiff, 33: } from '../../utils/gitDiff.js' 34: import { lazySchema } from '../../utils/lazySchema.js' 35: import { logError } from '../../utils/log.js' 36: import { expandPath } from '../../utils/path.js' 37: import { 38: checkWritePermissionForTool, 39: matchingRuleForInput, 40: } from '../../utils/permissions/filesystem.js' 41: import type { PermissionDecision } from '../../utils/permissions/PermissionResult.js' 42: import { matchWildcardPattern } from '../../utils/permissions/shellRuleMatching.js' 43: import { FILE_UNEXPECTEDLY_MODIFIED_ERROR } from '../FileEditTool/constants.js' 44: import { gitDiffSchema, hunkSchema } from '../FileEditTool/types.js' 45: import { FILE_WRITE_TOOL_NAME, getWriteToolDescription } from './prompt.js' 46: import { 47: getToolUseSummary, 48: isResultTruncated, 49: renderToolResultMessage, 50: renderToolUseErrorMessage, 51: renderToolUseMessage, 52: renderToolUseRejectedMessage, 53: userFacingName, 54: } from './UI.js' 55: const inputSchema = lazySchema(() => 56: z.strictObject({ 57: file_path: z 58: .string() 59: .describe( 60: 'The absolute path to the file to write (must be absolute, not relative)', 61: ), 62: content: z.string().describe('The content to write to the file'), 63: }), 64: ) 65: type InputSchema = ReturnType<typeof inputSchema> 66: const outputSchema = lazySchema(() => 67: z.object({ 68: type: z 69: .enum(['create', 'update']) 70: .describe( 71: 'Whether a new file was created or an existing file was updated', 72: ), 73: filePath: z.string().describe('The path to the file that was written'), 74: content: z.string().describe('The content that was written to the file'), 75: structuredPatch: z 76: .array(hunkSchema()) 77: .describe('Diff patch showing the changes'), 78: originalFile: z 79: .string() 80: .nullable() 81: .describe( 82: 'The original file content before the write (null for new files)', 83: ), 84: gitDiff: gitDiffSchema().optional(), 85: }), 86: ) 87: type OutputSchema = ReturnType<typeof outputSchema> 88: export type Output = z.infer<OutputSchema> 89: export type FileWriteToolInput = InputSchema 90: export const FileWriteTool = buildTool({ 91: name: FILE_WRITE_TOOL_NAME, 92: searchHint: 'create or overwrite files', 93: maxResultSizeChars: 100_000, 94: strict: true, 95: async description() { 96: return 'Write a file to the local filesystem.' 97: }, 98: userFacingName, 99: getToolUseSummary, 100: getActivityDescription(input) { 101: const summary = getToolUseSummary(input) 102: return summary ? `Writing ${summary}` : 'Writing file' 103: }, 104: async prompt() { 105: return getWriteToolDescription() 106: }, 107: renderToolUseMessage, 108: isResultTruncated, 109: get inputSchema(): InputSchema { 110: return inputSchema() 111: }, 112: get outputSchema(): OutputSchema { 113: return outputSchema() 114: }, 115: toAutoClassifierInput(input) { 116: return `${input.file_path}: ${input.content}` 117: }, 118: getPath(input): string { 119: return input.file_path 120: }, 121: backfillObservableInput(input) { 122: if (typeof input.file_path === 'string') { 123: input.file_path = expandPath(input.file_path) 124: } 125: }, 126: async preparePermissionMatcher({ file_path }) { 127: return pattern => matchWildcardPattern(pattern, file_path) 128: }, 129: async checkPermissions(input, context): Promise<PermissionDecision> { 130: const appState = context.getAppState() 131: return checkWritePermissionForTool( 132: FileWriteTool, 133: input, 134: appState.toolPermissionContext, 135: ) 136: }, 137: renderToolUseRejectedMessage, 138: renderToolUseErrorMessage, 139: renderToolResultMessage, 140: extractSearchText() { 141: return '' 142: }, 143: async validateInput({ file_path, content }, toolUseContext: ToolUseContext) { 144: const fullFilePath = expandPath(file_path) 145: // Reject writes to team memory files that contain secrets 146: const secretError = checkTeamMemSecrets(fullFilePath, content) 147: if (secretError) { 148: return { result: false, message: secretError, errorCode: 0 } 149: } 150: // Check if path should be ignored based on permission settings 151: const appState = toolUseContext.getAppState() 152: const denyRule = matchingRuleForInput( 153: fullFilePath, 154: appState.toolPermissionContext, 155: 'edit', 156: 'deny', 157: ) 158: if (denyRule !== null) { 159: return { 160: result: false, 161: message: 162: 'File is in a directory that is denied by your permission settings.', 163: errorCode: 1, 164: } 165: } 166: if (fullFilePath.startsWith('\\\\') || fullFilePath.startsWith(' 167: return { result: true } 168: } 169: const fs = getFsImplementation() 170: let fileMtimeMs: number 171: try { 172: const fileStat = await fs.stat(fullFilePath) 173: fileMtimeMs = fileStat.mtimeMs 174: } catch (e) { 175: if (isENOENT(e)) { 176: return { result: true } 177: } 178: throw e 179: } 180: const readTimestamp = toolUseContext.readFileState.get(fullFilePath) 181: if (!readTimestamp || readTimestamp.isPartialView) { 182: return { 183: result: false, 184: message: 185: 'File has not been read yet. Read it first before writing to it.', 186: errorCode: 2, 187: } 188: } 189: const lastWriteTime = Math.floor(fileMtimeMs) 190: if (lastWriteTime > readTimestamp.timestamp) { 191: return { 192: result: false, 193: message: 194: 'File has been modified since read, either by the user or by a linter. Read it again before attempting to write it.', 195: errorCode: 3, 196: } 197: } 198: return { result: true } 199: }, 200: async call( 201: { file_path, content }, 202: { readFileState, updateFileHistoryState, dynamicSkillDirTriggers }, 203: _, 204: parentMessage, 205: ) { 206: const fullFilePath = expandPath(file_path) 207: const dir = dirname(fullFilePath) 208: const cwd = getCwd() 209: const newSkillDirs = await discoverSkillDirsForPaths([fullFilePath], cwd) 210: if (newSkillDirs.length > 0) { 211: for (const dir of newSkillDirs) { 212: dynamicSkillDirTriggers?.add(dir) 213: } 214: addSkillDirectories(newSkillDirs).catch(() => {}) 215: } 216: activateConditionalSkillsForPaths([fullFilePath], cwd) 217: await diagnosticTracker.beforeFileEdited(fullFilePath) 218: await getFsImplementation().mkdir(dir) 219: if (fileHistoryEnabled()) { 220: await fileHistoryTrackEdit( 221: updateFileHistoryState, 222: fullFilePath, 223: parentMessage.uuid, 224: ) 225: } 226: let meta: ReturnType<typeof readFileSyncWithMetadata> | null 227: try { 228: meta = readFileSyncWithMetadata(fullFilePath) 229: } catch (e) { 230: if (isENOENT(e)) { 231: meta = null 232: } else { 233: throw e 234: } 235: } 236: if (meta !== null) { 237: const lastWriteTime = getFileModificationTime(fullFilePath) 238: const lastRead = readFileState.get(fullFilePath) 239: if (!lastRead || lastWriteTime > lastRead.timestamp) { 240: const isFullRead = 241: lastRead && 242: lastRead.offset === undefined && 243: lastRead.limit === undefined 244: if (!isFullRead || meta.content !== lastRead.content) { 245: throw new Error(FILE_UNEXPECTEDLY_MODIFIED_ERROR) 246: } 247: } 248: } 249: const enc = meta?.encoding ?? 'utf8' 250: const oldContent = meta?.content ?? null 251: writeTextContent(fullFilePath, content, enc, 'LF') 252: const lspManager = getLspServerManager() 253: if (lspManager) { 254: clearDeliveredDiagnosticsForFile(`file://${fullFilePath}`) 255: lspManager.changeFile(fullFilePath, content).catch((err: Error) => { 256: logForDebugging( 257: `LSP: Failed to notify server of file change for ${fullFilePath}: ${err.message}`, 258: ) 259: logError(err) 260: }) 261: lspManager.saveFile(fullFilePath).catch((err: Error) => { 262: logForDebugging( 263: `LSP: Failed to notify server of file save for ${fullFilePath}: ${err.message}`, 264: ) 265: logError(err) 266: }) 267: } 268: notifyVscodeFileUpdated(fullFilePath, oldContent, content) 269: readFileState.set(fullFilePath, { 270: content, 271: timestamp: getFileModificationTime(fullFilePath), 272: offset: undefined, 273: limit: undefined, 274: }) 275: if (fullFilePath.endsWith(`${sep}CLAUDE.md`)) { 276: logEvent('tengu_write_claudemd', {}) 277: } 278: let gitDiff: ToolUseDiff | undefined 279: if ( 280: isEnvTruthy(process.env.CLAUDE_CODE_REMOTE) && 281: getFeatureValue_CACHED_MAY_BE_STALE('tengu_quartz_lantern', false) 282: ) { 283: const startTime = Date.now() 284: const diff = await fetchSingleFileGitDiff(fullFilePath) 285: if (diff) gitDiff = diff 286: logEvent('tengu_tool_use_diff_computed', { 287: isWriteTool: true, 288: durationMs: Date.now() - startTime, 289: hasDiff: !!diff, 290: }) 291: } 292: if (oldContent) { 293: const patch = getPatchForDisplay({ 294: filePath: file_path, 295: fileContents: oldContent, 296: edits: [ 297: { 298: old_string: oldContent, 299: new_string: content, 300: replace_all: false, 301: }, 302: ], 303: }) 304: const data = { 305: type: 'update' as const, 306: filePath: file_path, 307: content, 308: structuredPatch: patch, 309: originalFile: oldContent, 310: ...(gitDiff && { gitDiff }), 311: } 312: countLinesChanged(patch) 313: logFileOperation({ 314: operation: 'write', 315: tool: 'FileWriteTool', 316: filePath: fullFilePath, 317: type: 'update', 318: }) 319: return { 320: data, 321: } 322: } 323: const data = { 324: type: 'create' as const, 325: filePath: file_path, 326: content, 327: structuredPatch: [], 328: originalFile: null, 329: ...(gitDiff && { gitDiff }), 330: } 331: countLinesChanged([], content) 332: logFileOperation({ 333: operation: 'write', 334: tool: 'FileWriteTool', 335: filePath: fullFilePath, 336: type: 'create', 337: }) 338: return { 339: data, 340: } 341: }, 342: mapToolResultToToolResultBlockParam({ filePath, type }, toolUseID) { 343: switch (type) { 344: case 'create': 345: return { 346: tool_use_id: toolUseID, 347: type: 'tool_result', 348: content: `File created successfully at: ${filePath}`, 349: } 350: case 'update': 351: return { 352: tool_use_id: toolUseID, 353: type: 'tool_result', 354: content: `The file ${filePath} has been updated successfully.`, 355: } 356: } 357: }, 358: } satisfies ToolDef<InputSchema, Output>)

File: src/tools/FileWriteTool/prompt.ts

typescript 1: import { FILE_READ_TOOL_NAME } from '../FileReadTool/prompt.js' 2: export const FILE_WRITE_TOOL_NAME = 'Write' 3: export const DESCRIPTION = 'Write a file to the local filesystem.' 4: function getPreReadInstruction(): string { 5: return `\n- If this is an existing file, you MUST use the ${FILE_READ_TOOL_NAME} tool first to read the file's contents. This tool will fail if you did not read the file first.` 6: } 7: export function getWriteToolDescription(): string { 8: return `Writes a file to the local filesystem. 9: Usage: 10: - This tool will overwrite the existing file if there is one at the provided path.${getPreReadInstruction()} 11: - Prefer the Edit tool for modifying existing files \u2014 it only sends the diff. Only use this tool to create new files or for complete rewrites. 12: - NEVER create documentation files (*.md) or README files unless explicitly requested by the User. 13: - Only use emojis if the user explicitly requests it. Avoid writing emojis to files unless asked.` 14: }

File: src/tools/FileWriteTool/UI.tsx

typescript 1: import { c as _c } from "react/compiler-runtime"; 2: import type { ToolResultBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'; 3: import type { StructuredPatchHunk } from 'diff'; 4: import { isAbsolute, relative, resolve } from 'path'; 5: import * as React from 'react'; 6: import { Suspense, use, useState } from 'react'; 7: import { MessageResponse } from 'src/components/MessageResponse.js'; 8: import { extractTag } from 'src/utils/messages.js'; 9: import { CtrlOToExpand } from '../../components/CtrlOToExpand.js'; 10: import { FallbackToolUseErrorMessage } from '../../components/FallbackToolUseErrorMessage.js'; 11: import { FileEditToolUpdatedMessage } from '../../components/FileEditToolUpdatedMessage.js'; 12: import { FileEditToolUseRejectedMessage } from '../../components/FileEditToolUseRejectedMessage.js'; 13: import { FilePathLink } from '../../components/FilePathLink.js'; 14: import { HighlightedCode } from '../../components/HighlightedCode.js'; 15: import { useTerminalSize } from '../../hooks/useTerminalSize.js'; 16: import { Box, Text } from '../../ink.js'; 17: import type { ToolProgressData } from '../../Tool.js'; 18: import type { ProgressMessage } from '../../types/message.js'; 19: import { getCwd } from '../../utils/cwd.js'; 20: import { getPatchForDisplay } from '../../utils/diff.js'; 21: import { getDisplayPath } from '../../utils/file.js'; 22: import { logError } from '../../utils/log.js'; 23: import { getPlansDirectory } from '../../utils/plans.js'; 24: import { openForScan, readCapped } from '../../utils/readEditContext.js'; 25: import type { Output } from './FileWriteTool.js'; 26: const MAX_LINES_TO_RENDER = 10; 27: const EOL = '\n'; 28: export function countLines(content: string): number { 29: const parts = content.split(EOL); 30: return content.endsWith(EOL) ? parts.length - 1 : parts.length; 31: } 32: function FileWriteToolCreatedMessage(t0) { 33: const $ = _c(25); 34: const { 35: filePath, 36: content, 37: verbose 38: } = t0; 39: const { 40: columns 41: } = useTerminalSize(); 42: const contentWithFallback = content || "(No content)"; 43: const numLines = countLines(content); 44: const plusLines = numLines - MAX_LINES_TO_RENDER; 45: let t1; 46: if ($[0] !== numLines) { 47: t1 = <Text bold={true}>{numLines}</Text>; 48: $[0] = numLines; 49: $[1] = t1; 50: } else { 51: t1 = $[1]; 52: } 53: let t2; 54: if ($[2] !== filePath || $[3] !== verbose) { 55: t2 = verbose ? filePath : relative(getCwd(), filePath); 56: $[2] = filePath; 57: $[3] = verbose; 58: $[4] = t2; 59: } else { 60: t2 = $[4]; 61: } 62: let t3; 63: if ($[5] !== t2) { 64: t3 = <Text bold={true}>{t2}</Text>; 65: $[5] = t2; 66: $[6] = t3; 67: } else { 68: t3 = $[6]; 69: } 70: let t4; 71: if ($[7] !== t1 || $[8] !== t3) { 72: t4 = <Text>Wrote {t1} lines to{" "}{t3}</Text>; 73: $[7] = t1; 74: $[8] = t3; 75: $[9] = t4; 76: } else { 77: t4 = $[9]; 78: } 79: let t5; 80: if ($[10] !== contentWithFallback || $[11] !== verbose) { 81: t5 = verbose ? contentWithFallback : contentWithFallback.split("\n").slice(0, MAX_LINES_TO_RENDER).join("\n"); 82: $[10] = contentWithFallback; 83: $[11] = verbose; 84: $[12] = t5; 85: } else { 86: t5 = $[12]; 87: } 88: const t6 = columns - 12; 89: let t7; 90: if ($[13] !== filePath || $[14] !== t5 || $[15] !== t6) { 91: t7 = <Box flexDirection="column"><HighlightedCode code={t5} filePath={filePath} width={t6} /></Box>; 92: $[13] = filePath; 93: $[14] = t5; 94: $[15] = t6; 95: $[16] = t7; 96: } else { 97: t7 = $[16]; 98: } 99: let t8; 100: if ($[17] !== numLines || $[18] !== plusLines || $[19] !== verbose) { 101: t8 = !verbose && plusLines > 0 && <Text dimColor={true}>… +{plusLines} {plusLines === 1 ? "line" : "lines"}{" "}{numLines > 0 && <CtrlOToExpand />}</Text>; 102: $[17] = numLines; 103: $[18] = plusLines; 104: $[19] = verbose; 105: $[20] = t8; 106: } else { 107: t8 = $[20]; 108: } 109: let t9; 110: if ($[21] !== t4 || $[22] !== t7 || $[23] !== t8) { 111: t9 = <MessageResponse><Box flexDirection="column">{t4}{t7}{t8}</Box></MessageResponse>; 112: $[21] = t4; 113: $[22] = t7; 114: $[23] = t8; 115: $[24] = t9; 116: } else { 117: t9 = $[24]; 118: } 119: return t9; 120: } 121: export function userFacingName(input: Partial<{ 122: file_path: string; 123: content: string; 124: }> | undefined): string { 125: if (input?.file_path?.startsWith(getPlansDirectory())) { 126: return 'Updated plan'; 127: } 128: return 'Write'; 129: } 130: export function isResultTruncated({ 131: type, 132: content 133: }: Output): boolean { 134: if (type !== 'create') return false; 135: let pos = 0; 136: for (let i = 0; i < MAX_LINES_TO_RENDER; i++) { 137: pos = content.indexOf(EOL, pos); 138: if (pos === -1) return false; 139: pos++; 140: } 141: return pos < content.length; 142: } 143: export function getToolUseSummary(input: Partial<{ 144: file_path: string; 145: content: string; 146: }> | undefined): string | null { 147: if (!input?.file_path) { 148: return null; 149: } 150: return getDisplayPath(input.file_path); 151: } 152: export function renderToolUseMessage(input: Partial<{ 153: file_path: string; 154: content: string; 155: }>, { 156: verbose 157: }: { 158: verbose: boolean; 159: }): React.ReactNode { 160: if (!input.file_path) { 161: return null; 162: } 163: if (input.file_path.startsWith(getPlansDirectory())) { 164: return ''; 165: } 166: return <FilePathLink filePath={input.file_path}> 167: {verbose ? input.file_path : getDisplayPath(input.file_path)} 168: </FilePathLink>; 169: } 170: export function renderToolUseRejectedMessage({ 171: file_path, 172: content 173: }: { 174: file_path: string; 175: content: string; 176: }, { 177: style, 178: verbose 179: }: { 180: style?: 'condensed'; 181: verbose: boolean; 182: }): React.ReactNode { 183: return <WriteRejectionDiff filePath={file_path} content={content} style={style} verbose={verbose} />; 184: } 185: type RejectionDiffData = { 186: type: 'create'; 187: } | { 188: type: 'update'; 189: patch: StructuredPatchHunk[]; 190: oldContent: string; 191: } | { 192: type: 'error'; 193: }; 194: function WriteRejectionDiff(t0) { 195: const $ = _c(20); 196: const { 197: filePath, 198: content, 199: style, 200: verbose 201: } = t0; 202: let t1; 203: if ($[0] !== content || $[1] !== filePath) { 204: t1 = () => loadRejectionDiff(filePath, content); 205: $[0] = content; 206: $[1] = filePath; 207: $[2] = t1; 208: } else { 209: t1 = $[2]; 210: } 211: const [dataPromise] = useState(t1); 212: let t2; 213: if ($[3] !== content) { 214: t2 = content.split("\n")[0] ?? null; 215: $[3] = content; 216: $[4] = t2; 217: } else { 218: t2 = $[4]; 219: } 220: const firstLine = t2; 221: let t3; 222: if ($[5] !== content || $[6] !== filePath || $[7] !== firstLine || $[8] !== verbose) { 223: t3 = <FileEditToolUseRejectedMessage file_path={filePath} operation="write" content={content} firstLine={firstLine} verbose={verbose} />; 224: $[5] = content; 225: $[6] = filePath; 226: $[7] = firstLine; 227: $[8] = verbose; 228: $[9] = t3; 229: } else { 230: t3 = $[9]; 231: } 232: const createFallback = t3; 233: let t4; 234: if ($[10] !== createFallback || $[11] !== dataPromise || $[12] !== filePath || $[13] !== firstLine || $[14] !== style || $[15] !== verbose) { 235: t4 = <WriteRejectionBody promise={dataPromise} filePath={filePath} firstLine={firstLine} createFallback={createFallback} style={style} verbose={verbose} />; 236: $[10] = createFallback; 237: $[11] = dataPromise; 238: $[12] = filePath; 239: $[13] = firstLine; 240: $[14] = style; 241: $[15] = verbose; 242: $[16] = t4; 243: } else { 244: t4 = $[16]; 245: } 246: let t5; 247: if ($[17] !== createFallback || $[18] !== t4) { 248: t5 = <Suspense fallback={createFallback}>{t4}</Suspense>; 249: $[17] = createFallback; 250: $[18] = t4; 251: $[19] = t5; 252: } else { 253: t5 = $[19]; 254: } 255: return t5; 256: } 257: function WriteRejectionBody(t0) { 258: const $ = _c(8); 259: const { 260: promise, 261: filePath, 262: firstLine, 263: createFallback, 264: style, 265: verbose 266: } = t0; 267: const data = use(promise); 268: if (data.type === "create") { 269: return createFallback; 270: } 271: if (data.type === "error") { 272: let t1; 273: if ($[0] === Symbol.for("react.memo_cache_sentinel")) { 274: t1 = <MessageResponse><Text>(No changes)</Text></MessageResponse>; 275: $[0] = t1; 276: } else { 277: t1 = $[0]; 278: } 279: return t1; 280: } 281: let t1; 282: if ($[1] !== data.oldContent || $[2] !== data.patch || $[3] !== filePath || $[4] !== firstLine || $[5] !== style || $[6] !== verbose) { 283: t1 = <FileEditToolUseRejectedMessage file_path={filePath} operation="update" patch={data.patch} firstLine={firstLine} fileContent={data.oldContent} style={style} verbose={verbose} />; 284: $[1] = data.oldContent; 285: $[2] = data.patch; 286: $[3] = filePath; 287: $[4] = firstLine; 288: $[5] = style; 289: $[6] = verbose; 290: $[7] = t1; 291: } else { 292: t1 = $[7]; 293: } 294: return t1; 295: } 296: async function loadRejectionDiff(filePath: string, content: string): Promise<RejectionDiffData> { 297: try { 298: const fullFilePath = isAbsolute(filePath) ? filePath : resolve(getCwd(), filePath); 299: const handle = await openForScan(fullFilePath); 300: if (handle === null) return { 301: type: 'create' 302: }; 303: let oldContent: string | null; 304: try { 305: oldContent = await readCapped(handle); 306: } finally { 307: await handle.close(); 308: } 309: if (oldContent === null) return { 310: type: 'create' 311: }; 312: const patch = getPatchForDisplay({ 313: filePath, 314: fileContents: oldContent, 315: edits: [{ 316: old_string: oldContent, 317: new_string: content, 318: replace_all: false 319: }] 320: }); 321: return { 322: type: 'update', 323: patch, 324: oldContent 325: }; 326: } catch (e) { 327: logError(e as Error); 328: return { 329: type: 'error' 330: }; 331: } 332: } 333: export function renderToolUseErrorMessage(result: ToolResultBlockParam['content'], { 334: verbose 335: }: { 336: verbose: boolean; 337: }): React.ReactNode { 338: if (!verbose && typeof result === 'string' && extractTag(result, 'tool_use_error')) { 339: return <MessageResponse> 340: <Text color="error">Error writing file</Text> 341: </MessageResponse>; 342: } 343: return <FallbackToolUseErrorMessage result={result} verbose={verbose} />; 344: } 345: export function renderToolResultMessage({ 346: filePath, 347: content, 348: structuredPatch, 349: type, 350: originalFile 351: }: Output, _progressMessagesForMessage: ProgressMessage<ToolProgressData>[], { 352: style, 353: verbose 354: }: { 355: style?: 'condensed'; 356: verbose: boolean; 357: }): React.ReactNode { 358: switch (type) { 359: case 'create': 360: { 361: const isPlanFile = filePath.startsWith(getPlansDirectory()); 362: if (isPlanFile && !verbose) { 363: if (style !== 'condensed') { 364: return <MessageResponse> 365: <Text dimColor>/plan to preview</Text> 366: </MessageResponse>; 367: } 368: } else if (style === 'condensed' && !verbose) { 369: const numLines = countLines(content); 370: return <Text> 371: Wrote <Text bold>{numLines}</Text> lines to{' '} 372: <Text bold>{relative(getCwd(), filePath)}</Text> 373: </Text>; 374: } 375: return <FileWriteToolCreatedMessage filePath={filePath} content={content} verbose={verbose} />; 376: } 377: case 'update': 378: { 379: const isPlanFile = filePath.startsWith(getPlansDirectory()); 380: return <FileEditToolUpdatedMessage filePath={filePath} structuredPatch={structuredPatch} firstLine={content.split('\n')[0] ?? null} fileContent={originalFile ?? undefined} style={style} verbose={verbose} previewHint={isPlanFile ? '/plan to preview' : undefined} />; 381: } 382: } 383: }

File: src/tools/GlobTool/GlobTool.ts

typescript 1: import { z } from 'zod/v4' 2: import type { ValidationResult } from '../../Tool.js' 3: import { buildTool, type ToolDef } from '../../Tool.js' 4: import { getCwd } from '../../utils/cwd.js' 5: import { isENOENT } from '../../utils/errors.js' 6: import { 7: FILE_NOT_FOUND_CWD_NOTE, 8: suggestPathUnderCwd, 9: } from '../../utils/file.js' 10: import { getFsImplementation } from '../../utils/fsOperations.js' 11: import { glob } from '../../utils/glob.js' 12: import { lazySchema } from '../../utils/lazySchema.js' 13: import { expandPath, toRelativePath } from '../../utils/path.js' 14: import { checkReadPermissionForTool } from '../../utils/permissions/filesystem.js' 15: import type { PermissionDecision } from '../../utils/permissions/PermissionResult.js' 16: import { matchWildcardPattern } from '../../utils/permissions/shellRuleMatching.js' 17: import { DESCRIPTION, GLOB_TOOL_NAME } from './prompt.js' 18: import { 19: getToolUseSummary, 20: renderToolResultMessage, 21: renderToolUseErrorMessage, 22: renderToolUseMessage, 23: userFacingName, 24: } from './UI.js' 25: const inputSchema = lazySchema(() => 26: z.strictObject({ 27: pattern: z.string().describe('The glob pattern to match files against'), 28: path: z 29: .string() 30: .optional() 31: .describe( 32: 'The directory to search in. If not specified, the current working directory will be used. IMPORTANT: Omit this field to use the default directory. DO NOT enter "undefined" or "null" - simply omit it for the default behavior. Must be a valid directory path if provided.', 33: ), 34: }), 35: ) 36: type InputSchema = ReturnType<typeof inputSchema> 37: const outputSchema = lazySchema(() => 38: z.object({ 39: durationMs: z 40: .number() 41: .describe('Time taken to execute the search in milliseconds'), 42: numFiles: z.number().describe('Total number of files found'), 43: filenames: z 44: .array(z.string()) 45: .describe('Array of file paths that match the pattern'), 46: truncated: z 47: .boolean() 48: .describe('Whether results were truncated (limited to 100 files)'), 49: }), 50: ) 51: type OutputSchema = ReturnType<typeof outputSchema> 52: export type Output = z.infer<OutputSchema> 53: export const GlobTool = buildTool({ 54: name: GLOB_TOOL_NAME, 55: searchHint: 'find files by name pattern or wildcard', 56: maxResultSizeChars: 100_000, 57: async description() { 58: return DESCRIPTION 59: }, 60: userFacingName, 61: getToolUseSummary, 62: getActivityDescription(input) { 63: const summary = getToolUseSummary(input) 64: return summary ? `Finding ${summary}` : 'Finding files' 65: }, 66: get inputSchema(): InputSchema { 67: return inputSchema() 68: }, 69: get outputSchema(): OutputSchema { 70: return outputSchema() 71: }, 72: isConcurrencySafe() { 73: return true 74: }, 75: isReadOnly() { 76: return true 77: }, 78: toAutoClassifierInput(input) { 79: return input.pattern 80: }, 81: isSearchOrReadCommand() { 82: return { isSearch: true, isRead: false } 83: }, 84: getPath({ path }): string { 85: return path ? expandPath(path) : getCwd() 86: }, 87: async preparePermissionMatcher({ pattern }) { 88: return rulePattern => matchWildcardPattern(rulePattern, pattern) 89: }, 90: async validateInput({ path }): Promise<ValidationResult> { 91: if (path) { 92: const fs = getFsImplementation() 93: const absolutePath = expandPath(path) 94: if (absolutePath.startsWith('\\\\') || absolutePath.startsWith(' 95: return { result: true } 96: } 97: let stats 98: try { 99: stats = await fs.stat(absolutePath) 100: } catch (e: unknown) { 101: if (isENOENT(e)) { 102: const cwdSuggestion = await suggestPathUnderCwd(absolutePath) 103: let message = `Directory does not exist: ${path}. ${FILE_NOT_FOUND_CWD_NOTE} ${getCwd()}.` 104: if (cwdSuggestion) { 105: message += ` Did you mean ${cwdSuggestion}?` 106: } 107: return { 108: result: false, 109: message, 110: errorCode: 1, 111: } 112: } 113: throw e 114: } 115: if (!stats.isDirectory()) { 116: return { 117: result: false, 118: message: `Path is not a directory: ${path}`, 119: errorCode: 2, 120: } 121: } 122: } 123: return { result: true } 124: }, 125: async checkPermissions(input, context): Promise<PermissionDecision> { 126: const appState = context.getAppState() 127: return checkReadPermissionForTool( 128: GlobTool, 129: input, 130: appState.toolPermissionContext, 131: ) 132: }, 133: async prompt() { 134: return DESCRIPTION 135: }, 136: renderToolUseMessage, 137: renderToolUseErrorMessage, 138: renderToolResultMessage, 139: extractSearchText({ filenames }) { 140: return filenames.join('\n') 141: }, 142: async call(input, { abortController, getAppState, globLimits }) { 143: const start = Date.now() 144: const appState = getAppState() 145: const limit = globLimits?.maxResults ?? 100 146: const { files, truncated } = await glob( 147: input.pattern, 148: GlobTool.getPath(input), 149: { limit, offset: 0 }, 150: abortController.signal, 151: appState.toolPermissionContext, 152: ) 153: const filenames = files.map(toRelativePath) 154: const output: Output = { 155: filenames, 156: durationMs: Date.now() - start, 157: numFiles: filenames.length, 158: truncated, 159: } 160: return { 161: data: output, 162: } 163: }, 164: mapToolResultToToolResultBlockParam(output, toolUseID) { 165: if (output.filenames.length === 0) { 166: return { 167: tool_use_id: toolUseID, 168: type: 'tool_result', 169: content: 'No files found', 170: } 171: } 172: return { 173: tool_use_id: toolUseID, 174: type: 'tool_result', 175: content: [ 176: ...output.filenames, 177: ...(output.truncated 178: ? [ 179: '(Results are truncated. Consider using a more specific path or pattern.)', 180: ] 181: : []), 182: ].join('\n'), 183: } 184: }, 185: } satisfies ToolDef<InputSchema, Output>)

File: src/tools/GlobTool/prompt.ts

typescript 1: export const GLOB_TOOL_NAME = 'Glob' 2: export const DESCRIPTION = `- Fast file pattern matching tool that works with any codebase size 3: - Supports glob patterns like "**/*.js" or "src/**/*.ts" 4: - Returns matching file paths sorted by modification time 5: - Use this tool when you need to find files by name patterns 6: - When you are doing an open ended search that may require multiple rounds of globbing and grepping, use the Agent tool instead`

File: src/tools/GlobTool/UI.tsx

typescript 1: import type { ToolResultBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'; 2: import React from 'react'; 3: import { MessageResponse } from 'src/components/MessageResponse.js'; 4: import { extractTag } from 'src/utils/messages.js'; 5: import { FallbackToolUseErrorMessage } from '../../components/FallbackToolUseErrorMessage.js'; 6: import { TOOL_SUMMARY_MAX_LENGTH } from '../../constants/toolLimits.js'; 7: import { Text } from '../../ink.js'; 8: import { FILE_NOT_FOUND_CWD_NOTE, getDisplayPath } from '../../utils/file.js'; 9: import { truncate } from '../../utils/format.js'; 10: import { GrepTool } from '../GrepTool/GrepTool.js'; 11: export function userFacingName(): string { 12: return 'Search'; 13: } 14: export function renderToolUseMessage({ 15: pattern, 16: path 17: }: Partial<{ 18: pattern: string; 19: path: string; 20: }>, { 21: verbose 22: }: { 23: verbose: boolean; 24: }): React.ReactNode { 25: if (!pattern) { 26: return null; 27: } 28: if (!path) { 29: return `pattern: "${pattern}"`; 30: } 31: return `pattern: "${pattern}", path: "${verbose ? path : getDisplayPath(path)}"`; 32: } 33: export function renderToolUseErrorMessage(result: ToolResultBlockParam['content'], { 34: verbose 35: }: { 36: verbose: boolean; 37: }): React.ReactNode { 38: if (!verbose && typeof result === 'string' && extractTag(result, 'tool_use_error')) { 39: const errorMessage = extractTag(result, 'tool_use_error'); 40: if (errorMessage?.includes(FILE_NOT_FOUND_CWD_NOTE)) { 41: return <MessageResponse> 42: <Text color="error">File not found</Text> 43: </MessageResponse>; 44: } 45: return <MessageResponse> 46: <Text color="error">Error searching files</Text> 47: </MessageResponse>; 48: } 49: return <FallbackToolUseErrorMessage result={result} verbose={verbose} />; 50: } 51: export const renderToolResultMessage = GrepTool.renderToolResultMessage; 52: export function getToolUseSummary(input: Partial<{ 53: pattern: string; 54: path: string; 55: }> | undefined): string | null { 56: if (!input?.pattern) { 57: return null; 58: } 59: return truncate(input.pattern, TOOL_SUMMARY_MAX_LENGTH); 60: }

File: src/tools/GrepTool/GrepTool.ts

typescript 1: import { z } from 'zod/v4' 2: import type { ValidationResult } from '../../Tool.js' 3: import { buildTool, type ToolDef } from '../../Tool.js' 4: import { getCwd } from '../../utils/cwd.js' 5: import { isENOENT } from '../../utils/errors.js' 6: import { 7: FILE_NOT_FOUND_CWD_NOTE, 8: suggestPathUnderCwd, 9: } from '../../utils/file.js' 10: import { getFsImplementation } from '../../utils/fsOperations.js' 11: import { lazySchema } from '../../utils/lazySchema.js' 12: import { expandPath, toRelativePath } from '../../utils/path.js' 13: import { 14: checkReadPermissionForTool, 15: getFileReadIgnorePatterns, 16: normalizePatternsToPath, 17: } from '../../utils/permissions/filesystem.js' 18: import type { PermissionDecision } from '../../utils/permissions/PermissionResult.js' 19: import { matchWildcardPattern } from '../../utils/permissions/shellRuleMatching.js' 20: import { getGlobExclusionsForPluginCache } from '../../utils/plugins/orphanedPluginFilter.js' 21: import { ripGrep } from '../../utils/ripgrep.js' 22: import { semanticBoolean } from '../../utils/semanticBoolean.js' 23: import { semanticNumber } from '../../utils/semanticNumber.js' 24: import { plural } from '../../utils/stringUtils.js' 25: import { GREP_TOOL_NAME, getDescription } from './prompt.js' 26: import { 27: getToolUseSummary, 28: renderToolResultMessage, 29: renderToolUseErrorMessage, 30: renderToolUseMessage, 31: } from './UI.js' 32: const inputSchema = lazySchema(() => 33: z.strictObject({ 34: pattern: z 35: .string() 36: .describe( 37: 'The regular expression pattern to search for in file contents', 38: ), 39: path: z 40: .string() 41: .optional() 42: .describe( 43: 'File or directory to search in (rg PATH). Defaults to current working directory.', 44: ), 45: glob: z 46: .string() 47: .optional() 48: .describe( 49: 'Glob pattern to filter files (e.g. "*.js", "*.{ts,tsx}") - maps to rg --glob', 50: ), 51: output_mode: z 52: .enum(['content', 'files_with_matches', 'count']) 53: .optional() 54: .describe( 55: 'Output mode: "content" shows matching lines (supports -A/-B/-C context, -n line numbers, head_limit), "files_with_matches" shows file paths (supports head_limit), "count" shows match counts (supports head_limit). Defaults to "files_with_matches".', 56: ), 57: '-B': semanticNumber(z.number().optional()).describe( 58: 'Number of lines to show before each match (rg -B). Requires output_mode: "content", ignored otherwise.', 59: ), 60: '-A': semanticNumber(z.number().optional()).describe( 61: 'Number of lines to show after each match (rg -A). Requires output_mode: "content", ignored otherwise.', 62: ), 63: '-C': semanticNumber(z.number().optional()).describe('Alias for context.'), 64: context: semanticNumber(z.number().optional()).describe( 65: 'Number of lines to show before and after each match (rg -C). Requires output_mode: "content", ignored otherwise.', 66: ), 67: '-n': semanticBoolean(z.boolean().optional()).describe( 68: 'Show line numbers in output (rg -n). Requires output_mode: "content", ignored otherwise. Defaults to true.', 69: ), 70: '-i': semanticBoolean(z.boolean().optional()).describe( 71: 'Case insensitive search (rg -i)', 72: ), 73: type: z 74: .string() 75: .optional() 76: .describe( 77: 'File type to search (rg --type). Common types: js, py, rust, go, java, etc. More efficient than include for standard file types.', 78: ), 79: head_limit: semanticNumber(z.number().optional()).describe( 80: 'Limit output to first N lines/entries, equivalent to "| head -N". Works across all output modes: content (limits output lines), files_with_matches (limits file paths), count (limits count entries). Defaults to 250 when unspecified. Pass 0 for unlimited (use sparingly — large result sets waste context).', 81: ), 82: offset: semanticNumber(z.number().optional()).describe( 83: 'Skip first N lines/entries before applying head_limit, equivalent to "| tail -n +N | head -N". Works across all output modes. Defaults to 0.', 84: ), 85: multiline: semanticBoolean(z.boolean().optional()).describe( 86: 'Enable multiline mode where . matches newlines and patterns can span lines (rg -U --multiline-dotall). Default: false.', 87: ), 88: }), 89: ) 90: type InputSchema = ReturnType<typeof inputSchema> 91: const VCS_DIRECTORIES_TO_EXCLUDE = [ 92: '.git', 93: '.svn', 94: '.hg', 95: '.bzr', 96: '.jj', 97: '.sl', 98: ] as const 99: const DEFAULT_HEAD_LIMIT = 250 100: function applyHeadLimit<T>( 101: items: T[], 102: limit: number | undefined, 103: offset: number = 0, 104: ): { items: T[]; appliedLimit: number | undefined } { 105: if (limit === 0) { 106: return { items: items.slice(offset), appliedLimit: undefined } 107: } 108: const effectiveLimit = limit ?? DEFAULT_HEAD_LIMIT 109: const sliced = items.slice(offset, offset + effectiveLimit) 110: const wasTruncated = items.length - offset > effectiveLimit 111: return { 112: items: sliced, 113: appliedLimit: wasTruncated ? effectiveLimit : undefined, 114: } 115: } 116: function formatLimitInfo( 117: appliedLimit: number | undefined, 118: appliedOffset: number | undefined, 119: ): string { 120: const parts: string[] = [] 121: if (appliedLimit !== undefined) parts.push(`limit: ${appliedLimit}`) 122: if (appliedOffset) parts.push(`offset: ${appliedOffset}`) 123: return parts.join(', ') 124: } 125: const outputSchema = lazySchema(() => 126: z.object({ 127: mode: z.enum(['content', 'files_with_matches', 'count']).optional(), 128: numFiles: z.number(), 129: filenames: z.array(z.string()), 130: content: z.string().optional(), 131: numLines: z.number().optional(), 132: numMatches: z.number().optional(), 133: appliedLimit: z.number().optional(), 134: appliedOffset: z.number().optional(), 135: }), 136: ) 137: type OutputSchema = ReturnType<typeof outputSchema> 138: type Output = z.infer<OutputSchema> 139: export const GrepTool = buildTool({ 140: name: GREP_TOOL_NAME, 141: searchHint: 'search file contents with regex (ripgrep)', 142: maxResultSizeChars: 20_000, 143: strict: true, 144: async description() { 145: return getDescription() 146: }, 147: userFacingName() { 148: return 'Search' 149: }, 150: getToolUseSummary, 151: getActivityDescription(input) { 152: const summary = getToolUseSummary(input) 153: return summary ? `Searching for ${summary}` : 'Searching' 154: }, 155: get inputSchema(): InputSchema { 156: return inputSchema() 157: }, 158: get outputSchema(): OutputSchema { 159: return outputSchema() 160: }, 161: isConcurrencySafe() { 162: return true 163: }, 164: isReadOnly() { 165: return true 166: }, 167: toAutoClassifierInput(input) { 168: return input.path ? `${input.pattern} in ${input.path}` : input.pattern 169: }, 170: isSearchOrReadCommand() { 171: return { isSearch: true, isRead: false } 172: }, 173: getPath({ path }): string { 174: return path || getCwd() 175: }, 176: async preparePermissionMatcher({ pattern }) { 177: return rulePattern => matchWildcardPattern(rulePattern, pattern) 178: }, 179: async validateInput({ path }): Promise<ValidationResult> { 180: if (path) { 181: const fs = getFsImplementation() 182: const absolutePath = expandPath(path) 183: if (absolutePath.startsWith('\\\\') || absolutePath.startsWith(' 184: return { result: true } 185: } 186: try { 187: await fs.stat(absolutePath) 188: } catch (e: unknown) { 189: if (isENOENT(e)) { 190: const cwdSuggestion = await suggestPathUnderCwd(absolutePath) 191: let message = `Path does not exist: ${path}. ${FILE_NOT_FOUND_CWD_NOTE} ${getCwd()}.` 192: if (cwdSuggestion) { 193: message += ` Did you mean ${cwdSuggestion}?` 194: } 195: return { 196: result: false, 197: message, 198: errorCode: 1, 199: } 200: } 201: throw e 202: } 203: } 204: return { result: true } 205: }, 206: async checkPermissions(input, context): Promise<PermissionDecision> { 207: const appState = context.getAppState() 208: return checkReadPermissionForTool( 209: GrepTool, 210: input, 211: appState.toolPermissionContext, 212: ) 213: }, 214: async prompt() { 215: return getDescription() 216: }, 217: renderToolUseMessage, 218: renderToolUseErrorMessage, 219: renderToolResultMessage, 220: extractSearchText({ mode, content, filenames }) { 221: if (mode === 'content' && content) return content 222: return filenames.join('\n') 223: }, 224: mapToolResultToToolResultBlockParam( 225: { 226: mode = 'files_with_matches', 227: numFiles, 228: filenames, 229: content, 230: numLines: _numLines, 231: numMatches, 232: appliedLimit, 233: appliedOffset, 234: }, 235: toolUseID, 236: ) { 237: if (mode === 'content') { 238: const limitInfo = formatLimitInfo(appliedLimit, appliedOffset) 239: const resultContent = content || 'No matches found' 240: const finalContent = limitInfo 241: ? `${resultContent}\n\n[Showing results with pagination = ${limitInfo}]` 242: : resultContent 243: return { 244: tool_use_id: toolUseID, 245: type: 'tool_result', 246: content: finalContent, 247: } 248: } 249: if (mode === 'count') { 250: const limitInfo = formatLimitInfo(appliedLimit, appliedOffset) 251: const rawContent = content || 'No matches found' 252: const matches = numMatches ?? 0 253: const files = numFiles ?? 0 254: const summary = `\n\nFound ${matches} total ${matches === 1 ? 'occurrence' : 'occurrences'} across ${files} ${files === 1 ? 'file' : 'files'}.${limitInfo ? ` with pagination = ${limitInfo}` : ''}` 255: return { 256: tool_use_id: toolUseID, 257: type: 'tool_result', 258: content: rawContent + summary, 259: } 260: } 261: const limitInfo = formatLimitInfo(appliedLimit, appliedOffset) 262: if (numFiles === 0) { 263: return { 264: tool_use_id: toolUseID, 265: type: 'tool_result', 266: content: 'No files found', 267: } 268: } 269: const result = `Found ${numFiles} ${plural(numFiles, 'file')}${limitInfo ? ` ${limitInfo}` : ''}\n${filenames.join('\n')}` 270: return { 271: tool_use_id: toolUseID, 272: type: 'tool_result', 273: content: result, 274: } 275: }, 276: async call( 277: { 278: pattern, 279: path, 280: glob, 281: type, 282: output_mode = 'files_with_matches', 283: '-B': context_before, 284: '-A': context_after, 285: '-C': context_c, 286: context, 287: '-n': show_line_numbers = true, 288: '-i': case_insensitive = false, 289: head_limit, 290: offset = 0, 291: multiline = false, 292: }, 293: { abortController, getAppState }, 294: ) { 295: const absolutePath = path ? expandPath(path) : getCwd() 296: const args = ['--hidden'] 297: for (const dir of VCS_DIRECTORIES_TO_EXCLUDE) { 298: args.push('--glob', `!${dir}`) 299: } 300: args.push('--max-columns', '500') 301: if (multiline) { 302: args.push('-U', '--multiline-dotall') 303: } 304: if (case_insensitive) { 305: args.push('-i') 306: } 307: if (output_mode === 'files_with_matches') { 308: args.push('-l') 309: } else if (output_mode === 'count') { 310: args.push('-c') 311: } 312: if (show_line_numbers && output_mode === 'content') { 313: args.push('-n') 314: } 315: if (output_mode === 'content') { 316: if (context !== undefined) { 317: args.push('-C', context.toString()) 318: } else if (context_c !== undefined) { 319: args.push('-C', context_c.toString()) 320: } else { 321: if (context_before !== undefined) { 322: args.push('-B', context_before.toString()) 323: } 324: if (context_after !== undefined) { 325: args.push('-A', context_after.toString()) 326: } 327: } 328: } 329: if (pattern.startsWith('-')) { 330: args.push('-e', pattern) 331: } else { 332: args.push(pattern) 333: } 334: if (type) { 335: args.push('--type', type) 336: } 337: if (glob) { 338: const globPatterns: string[] = [] 339: const rawPatterns = glob.split(/\s+/) 340: for (const rawPattern of rawPatterns) { 341: if (rawPattern.includes('{') && rawPattern.includes('}')) { 342: globPatterns.push(rawPattern) 343: } else { 344: globPatterns.push(...rawPattern.split(',').filter(Boolean)) 345: } 346: } 347: for (const globPattern of globPatterns.filter(Boolean)) { 348: args.push('--glob', globPattern) 349: } 350: } 351: const appState = getAppState() 352: const ignorePatterns = normalizePatternsToPath( 353: getFileReadIgnorePatterns(appState.toolPermissionContext), 354: getCwd(), 355: ) 356: for (const ignorePattern of ignorePatterns) { 357: const rgIgnorePattern = ignorePattern.startsWith('/') 358: ? `!${ignorePattern}` 359: : `!**/${ignorePattern}` 360: args.push('--glob', rgIgnorePattern) 361: } 362: for (const exclusion of await getGlobExclusionsForPluginCache( 363: absolutePath, 364: )) { 365: args.push('--glob', exclusion) 366: } 367: const results = await ripGrep(args, absolutePath, abortController.signal) 368: if (output_mode === 'content') { 369: const { items: limitedResults, appliedLimit } = applyHeadLimit( 370: results, 371: head_limit, 372: offset, 373: ) 374: const finalLines = limitedResults.map(line => { 375: const colonIndex = line.indexOf(':') 376: if (colonIndex > 0) { 377: const filePath = line.substring(0, colonIndex) 378: const rest = line.substring(colonIndex) 379: return toRelativePath(filePath) + rest 380: } 381: return line 382: }) 383: const output = { 384: mode: 'content' as const, 385: numFiles: 0, 386: filenames: [], 387: content: finalLines.join('\n'), 388: numLines: finalLines.length, 389: ...(appliedLimit !== undefined && { appliedLimit }), 390: ...(offset > 0 && { appliedOffset: offset }), 391: } 392: return { data: output } 393: } 394: if (output_mode === 'count') { 395: const { items: limitedResults, appliedLimit } = applyHeadLimit( 396: results, 397: head_limit, 398: offset, 399: ) 400: const finalCountLines = limitedResults.map(line => { 401: const colonIndex = line.lastIndexOf(':') 402: if (colonIndex > 0) { 403: const filePath = line.substring(0, colonIndex) 404: const count = line.substring(colonIndex) 405: return toRelativePath(filePath) + count 406: } 407: return line 408: }) 409: let totalMatches = 0 410: let fileCount = 0 411: for (const line of finalCountLines) { 412: const colonIndex = line.lastIndexOf(':') 413: if (colonIndex > 0) { 414: const countStr = line.substring(colonIndex + 1) 415: const count = parseInt(countStr, 10) 416: if (!isNaN(count)) { 417: totalMatches += count 418: fileCount += 1 419: } 420: } 421: } 422: const output = { 423: mode: 'count' as const, 424: numFiles: fileCount, 425: filenames: [], 426: content: finalCountLines.join('\n'), 427: numMatches: totalMatches, 428: ...(appliedLimit !== undefined && { appliedLimit }), 429: ...(offset > 0 && { appliedOffset: offset }), 430: } 431: return { data: output } 432: } 433: const stats = await Promise.allSettled( 434: results.map(_ => getFsImplementation().stat(_)), 435: ) 436: const sortedMatches = results 437: .map((_, i) => { 438: const r = stats[i]! 439: return [ 440: _, 441: r.status === 'fulfilled' ? (r.value.mtimeMs ?? 0) : 0, 442: ] as const 443: }) 444: .sort((a, b) => { 445: if (process.env.NODE_ENV === 'test') { 446: return a[0].localeCompare(b[0]) 447: } 448: const timeComparison = b[1] - a[1] 449: if (timeComparison === 0) { 450: return a[0].localeCompare(b[0]) 451: } 452: return timeComparison 453: }) 454: .map(_ => _[0]) 455: const { items: finalMatches, appliedLimit } = applyHeadLimit( 456: sortedMatches, 457: head_limit, 458: offset, 459: ) 460: const relativeMatches = finalMatches.map(toRelativePath) 461: const output = { 462: mode: 'files_with_matches' as const, 463: filenames: relativeMatches, 464: numFiles: relativeMatches.length, 465: ...(appliedLimit !== undefined && { appliedLimit }), 466: ...(offset > 0 && { appliedOffset: offset }), 467: } 468: return { 469: data: output, 470: } 471: }, 472: } satisfies ToolDef<InputSchema, Output>)

File: src/tools/GrepTool/prompt.ts

typescript 1: import { AGENT_TOOL_NAME } from '../AgentTool/constants.js' 2: import { BASH_TOOL_NAME } from '../BashTool/toolName.js' 3: export const GREP_TOOL_NAME = 'Grep' 4: export function getDescription(): string { 5: return `A powerful search tool built on ripgrep 6: Usage: 7: - ALWAYS use ${GREP_TOOL_NAME} for search tasks. NEVER invoke \`grep\` or \`rg\` as a ${BASH_TOOL_NAME} command. The ${GREP_TOOL_NAME} tool has been optimized for correct permissions and access. 8: - Supports full regex syntax (e.g., "log.*Error", "function\\s+\\w+") 9: - Filter files with glob parameter (e.g., "*.js", "**/*.tsx") or type parameter (e.g., "js", "py", "rust") 10: - Output modes: "content" shows matching lines, "files_with_matches" shows only file paths (default), "count" shows match counts 11: - Use ${AGENT_TOOL_NAME} tool for open-ended searches requiring multiple rounds 12: - Pattern syntax: Uses ripgrep (not grep) - literal braces need escaping (use \`interface\\{\\}\` to find \`interface{}\` in Go code) 13: - Multiline matching: By default patterns match within single lines only. For cross-line patterns like \`struct \\{[\\s\\S]*?field\`, use \`multiline: true\` 14: ` 15: }

File: src/tools/GrepTool/UI.tsx

typescript 1: import { c as _c } from "react/compiler-runtime"; 2: import type { ToolResultBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'; 3: import React from 'react'; 4: import { CtrlOToExpand } from '../../components/CtrlOToExpand.js'; 5: import { FallbackToolUseErrorMessage } from '../../components/FallbackToolUseErrorMessage.js'; 6: import { MessageResponse } from '../../components/MessageResponse.js'; 7: import { TOOL_SUMMARY_MAX_LENGTH } from '../../constants/toolLimits.js'; 8: import { Box, Text } from '../../ink.js'; 9: import type { ToolProgressData } from '../../Tool.js'; 10: import type { ProgressMessage } from '../../types/message.js'; 11: import { FILE_NOT_FOUND_CWD_NOTE, getDisplayPath } from '../../utils/file.js'; 12: import { truncate } from '../../utils/format.js'; 13: import { extractTag } from '../../utils/messages.js'; 14: function SearchResultSummary(t0) { 15: const $ = _c(26); 16: const { 17: count, 18: countLabel, 19: secondaryCount, 20: secondaryLabel, 21: content, 22: verbose 23: } = t0; 24: let t1; 25: if ($[0] !== count) { 26: t1 = <Text bold={true}>{count} </Text>; 27: $[0] = count; 28: $[1] = t1; 29: } else { 30: t1 = $[1]; 31: } 32: let t2; 33: if ($[2] !== count || $[3] !== countLabel) { 34: t2 = count === 0 || count > 1 ? countLabel : countLabel.slice(0, -1); 35: $[2] = count; 36: $[3] = countLabel; 37: $[4] = t2; 38: } else { 39: t2 = $[4]; 40: } 41: let t3; 42: if ($[5] !== t1 || $[6] !== t2) { 43: t3 = <Text>Found {t1}{t2}</Text>; 44: $[5] = t1; 45: $[6] = t2; 46: $[7] = t3; 47: } else { 48: t3 = $[7]; 49: } 50: const primaryText = t3; 51: let t4; 52: if ($[8] !== secondaryCount || $[9] !== secondaryLabel) { 53: t4 = secondaryCount !== undefined && secondaryLabel ? <Text>{" "}across <Text bold={true}>{secondaryCount} </Text>{secondaryCount === 0 || secondaryCount > 1 ? secondaryLabel : secondaryLabel.slice(0, -1)}</Text> : null; 54: $[8] = secondaryCount; 55: $[9] = secondaryLabel; 56: $[10] = t4; 57: } else { 58: t4 = $[10]; 59: } 60: const secondaryText = t4; 61: if (verbose) { 62: let t5; 63: if ($[11] === Symbol.for("react.memo_cache_sentinel")) { 64: t5 = <Text dimColor={true}>  ⎿  </Text>; 65: $[11] = t5; 66: } else { 67: t5 = $[11]; 68: } 69: let t6; 70: if ($[12] !== primaryText || $[13] !== secondaryText) { 71: t6 = <Box flexDirection="row"><Text>{t5}{primaryText}{secondaryText}</Text></Box>; 72: $[12] = primaryText; 73: $[13] = secondaryText; 74: $[14] = t6; 75: } else { 76: t6 = $[14]; 77: } 78: let t7; 79: if ($[15] !== content) { 80: t7 = <Box marginLeft={5}><Text>{content}</Text></Box>; 81: $[15] = content; 82: $[16] = t7; 83: } else { 84: t7 = $[16]; 85: } 86: let t8; 87: if ($[17] !== t6 || $[18] !== t7) { 88: t8 = <Box flexDirection="column">{t6}{t7}</Box>; 89: $[17] = t6; 90: $[18] = t7; 91: $[19] = t8; 92: } else { 93: t8 = $[19]; 94: } 95: return t8; 96: } 97: let t5; 98: if ($[20] !== count) { 99: t5 = count > 0 && <CtrlOToExpand />; 100: $[20] = count; 101: $[21] = t5; 102: } else { 103: t5 = $[21]; 104: } 105: let t6; 106: if ($[22] !== primaryText || $[23] !== secondaryText || $[24] !== t5) { 107: t6 = <MessageResponse height={1}><Text>{primaryText}{secondaryText} {t5}</Text></MessageResponse>; 108: $[22] = primaryText; 109: $[23] = secondaryText; 110: $[24] = t5; 111: $[25] = t6; 112: } else { 113: t6 = $[25]; 114: } 115: return t6; 116: } 117: type Output = { 118: mode?: 'content' | 'files_with_matches' | 'count'; 119: numFiles: number; 120: filenames: string[]; 121: content?: string; 122: numLines?: number; 123: numMatches?: number; 124: }; 125: export function renderToolUseMessage({ 126: pattern, 127: path 128: }: Partial<{ 129: pattern: string; 130: path?: string; 131: }>, { 132: verbose 133: }: { 134: verbose: boolean; 135: }): React.ReactNode { 136: if (!pattern) { 137: return null; 138: } 139: const parts = [`pattern: "${pattern}"`]; 140: if (path) { 141: parts.push(`path: "${verbose ? path : getDisplayPath(path)}"`); 142: } 143: return parts.join(', '); 144: } 145: export function renderToolUseErrorMessage(result: ToolResultBlockParam['content'], { 146: verbose 147: }: { 148: verbose: boolean; 149: }): React.ReactNode { 150: if (!verbose && typeof result === 'string' && extractTag(result, 'tool_use_error')) { 151: const errorMessage = extractTag(result, 'tool_use_error'); 152: if (errorMessage?.includes(FILE_NOT_FOUND_CWD_NOTE)) { 153: return <MessageResponse> 154: <Text color="error">File not found</Text> 155: </MessageResponse>; 156: } 157: return <MessageResponse> 158: <Text color="error">Error searching files</Text> 159: </MessageResponse>; 160: } 161: return <FallbackToolUseErrorMessage result={result} verbose={verbose} />; 162: } 163: export function renderToolResultMessage({ 164: mode = 'files_with_matches', 165: filenames, 166: numFiles, 167: content, 168: numLines, 169: numMatches 170: }: Output, _progressMessagesForMessage: ProgressMessage<ToolProgressData>[], { 171: verbose 172: }: { 173: verbose: boolean; 174: }): React.ReactNode { 175: if (mode === 'content') { 176: return <SearchResultSummary count={numLines ?? 0} countLabel="lines" content={content} verbose={verbose} />; 177: } 178: if (mode === 'count') { 179: return <SearchResultSummary count={numMatches ?? 0} countLabel="matches" secondaryCount={numFiles} secondaryLabel="files" content={content} verbose={verbose} />; 180: } 181: const fileListContent = filenames.map(filename => filename).join('\n'); 182: return <SearchResultSummary count={numFiles} countLabel="files" content={fileListContent} verbose={verbose} />; 183: } 184: export function getToolUseSummary(input: Partial<{ 185: pattern: string; 186: path?: string; 187: glob?: string; 188: type?: string; 189: output_mode?: 'content' | 'files_with_matches' | 'count'; 190: head_limit?: number; 191: }> | undefined): string | null { 192: if (!input?.pattern) { 193: return null; 194: } 195: return truncate(input.pattern, TOOL_SUMMARY_MAX_LENGTH); 196: }

File: src/tools/ListMcpResourcesTool/ListMcpResourcesTool.ts

typescript 1: import { z } from 'zod/v4' 2: import { 3: ensureConnectedClient, 4: fetchResourcesForClient, 5: } from '../../services/mcp/client.js' 6: import { buildTool, type ToolDef } from '../../Tool.js' 7: import { errorMessage } from '../../utils/errors.js' 8: import { lazySchema } from '../../utils/lazySchema.js' 9: import { logMCPError } from '../../utils/log.js' 10: import { jsonStringify } from '../../utils/slowOperations.js' 11: import { isOutputLineTruncated } from '../../utils/terminal.js' 12: import { DESCRIPTION, LIST_MCP_RESOURCES_TOOL_NAME, PROMPT } from './prompt.js' 13: import { renderToolResultMessage, renderToolUseMessage } from './UI.js' 14: const inputSchema = lazySchema(() => 15: z.object({ 16: server: z 17: .string() 18: .optional() 19: .describe('Optional server name to filter resources by'), 20: }), 21: ) 22: type InputSchema = ReturnType<typeof inputSchema> 23: const outputSchema = lazySchema(() => 24: z.array( 25: z.object({ 26: uri: z.string().describe('Resource URI'), 27: name: z.string().describe('Resource name'), 28: mimeType: z.string().optional().describe('MIME type of the resource'), 29: description: z.string().optional().describe('Resource description'), 30: server: z.string().describe('Server that provides this resource'), 31: }), 32: ), 33: ) 34: type OutputSchema = ReturnType<typeof outputSchema> 35: export type Output = z.infer<OutputSchema> 36: export const ListMcpResourcesTool = buildTool({ 37: isConcurrencySafe() { 38: return true 39: }, 40: isReadOnly() { 41: return true 42: }, 43: toAutoClassifierInput(input) { 44: return input.server ?? '' 45: }, 46: shouldDefer: true, 47: name: LIST_MCP_RESOURCES_TOOL_NAME, 48: searchHint: 'list resources from connected MCP servers', 49: maxResultSizeChars: 100_000, 50: async description() { 51: return DESCRIPTION 52: }, 53: async prompt() { 54: return PROMPT 55: }, 56: get inputSchema(): InputSchema { 57: return inputSchema() 58: }, 59: get outputSchema(): OutputSchema { 60: return outputSchema() 61: }, 62: async call(input, { options: { mcpClients } }) { 63: const { server: targetServer } = input 64: const clientsToProcess = targetServer 65: ? mcpClients.filter(client => client.name === targetServer) 66: : mcpClients 67: if (targetServer && clientsToProcess.length === 0) { 68: throw new Error( 69: `Server "${targetServer}" not found. Available servers: ${mcpClients.map(c => c.name).join(', ')}`, 70: ) 71: } 72: const results = await Promise.all( 73: clientsToProcess.map(async client => { 74: if (client.type !== 'connected') return [] 75: try { 76: const fresh = await ensureConnectedClient(client) 77: return await fetchResourcesForClient(fresh) 78: } catch (error) { 79: logMCPError(client.name, errorMessage(error)) 80: return [] 81: } 82: }), 83: ) 84: return { 85: data: results.flat(), 86: } 87: }, 88: renderToolUseMessage, 89: userFacingName: () => 'listMcpResources', 90: renderToolResultMessage, 91: isResultTruncated(output: Output): boolean { 92: return isOutputLineTruncated(jsonStringify(output)) 93: }, 94: mapToolResultToToolResultBlockParam(content, toolUseID) { 95: if (!content || content.length === 0) { 96: return { 97: tool_use_id: toolUseID, 98: type: 'tool_result', 99: content: 100: 'No resources found. MCP servers may still provide tools even if they have no resources.', 101: } 102: } 103: return { 104: tool_use_id: toolUseID, 105: type: 'tool_result', 106: content: jsonStringify(content), 107: } 108: }, 109: } satisfies ToolDef<InputSchema, Output>)

File: src/tools/ListMcpResourcesTool/prompt.ts

typescript 1: export const LIST_MCP_RESOURCES_TOOL_NAME = 'ListMcpResourcesTool' 2: export const DESCRIPTION = ` 3: Lists available resources from configured MCP servers. 4: Each resource object includes a 'server' field indicating which server it's from. 5: Usage examples: 6: - List all resources from all servers: \`listMcpResources\` 7: - List resources from a specific server: \`listMcpResources({ server: "myserver" })\` 8: ` 9: export const PROMPT = ` 10: List available resources from configured MCP servers. 11: Each returned resource will include all standard MCP resource fields plus a 'server' field 12: indicating which server the resource belongs to. 13: Parameters: 14: - server (optional): The name of a specific MCP server to get resources from. If not provided, 15: resources from all servers will be returned. 16: `

File: src/tools/ListMcpResourcesTool/UI.tsx

typescript 1: import * as React from 'react'; 2: import { MessageResponse } from '../../components/MessageResponse.js'; 3: import { OutputLine } from '../../components/shell/OutputLine.js'; 4: import { Text } from '../../ink.js'; 5: import type { ToolProgressData } from '../../Tool.js'; 6: import type { ProgressMessage } from '../../types/message.js'; 7: import { jsonStringify } from '../../utils/slowOperations.js'; 8: import type { Output } from './ListMcpResourcesTool.js'; 9: export function renderToolUseMessage(input: Partial<{ 10: server?: string; 11: }>): React.ReactNode { 12: return input.server ? `List MCP resources from server "${input.server}"` : `List all MCP resources`; 13: } 14: export function renderToolResultMessage(output: Output, _progressMessagesForMessage: ProgressMessage<ToolProgressData>[], { 15: verbose 16: }: { 17: verbose: boolean; 18: }): React.ReactNode { 19: if (!output || output.length === 0) { 20: return <MessageResponse height={1}> 21: <Text dimColor>(No resources found)</Text> 22: </MessageResponse>; 23: } 24: const formattedOutput = jsonStringify(output, null, 2); 25: return <OutputLine content={formattedOutput} verbose={verbose} />; 26: }

File: src/tools/LSPTool/formatters.ts

typescript 1: import { relative } from 'path' 2: import type { 3: CallHierarchyIncomingCall, 4: CallHierarchyItem, 5: CallHierarchyOutgoingCall, 6: DocumentSymbol, 7: Hover, 8: Location, 9: LocationLink, 10: MarkedString, 11: MarkupContent, 12: SymbolInformation, 13: SymbolKind, 14: } from 'vscode-languageserver-types' 15: import { logForDebugging } from '../../utils/debug.js' 16: import { errorMessage } from '../../utils/errors.js' 17: import { plural } from '../../utils/stringUtils.js' 18: function formatUri(uri: string | undefined, cwd?: string): string { 19: if (!uri) { 20: logForDebugging( 21: 'formatUri called with undefined URI - indicates malformed LSP server response', 22: { level: 'warn' }, 23: ) 24: return '<unknown location>' 25: } 26: let filePath = uri.replace(/^file:\/\//, '') 27: if (/^\/[A-Za-z]:/.test(filePath)) { 28: filePath = filePath.slice(1) 29: } 30: // Decode URI encoding - handle malformed URIs gracefully 31: try { 32: filePath = decodeURIComponent(filePath) 33: } catch (error) { 34: // Log for debugging but continue with un-decoded path 35: const errorMsg = errorMessage(error) 36: logForDebugging( 37: `Failed to decode LSP URI '${uri}': ${errorMsg}. Using un-decoded path: ${filePath}`, 38: { level: 'warn' }, 39: ) 40: } 41: if (cwd) { 42: const relativePath = relative(cwd, filePath).replaceAll('\\', '/') 43: // Only use relative path if it's shorter and doesn't start with ../.. 44: if ( 45: relativePath.length < filePath.length && 46: !relativePath.startsWith('../../') 47: ) { 48: return relativePath 49: } 50: } 51: return filePath.replaceAll('\\', '/') 52: } 53: /** 54: * Groups items by their file URI. 55: * Generic helper that works with both Location[] and SymbolInformation[] 56: */ 57: function groupByFile<T extends { uri: string } | { location: { uri: string } }>( 58: items: T[], 59: cwd?: string, 60: ): Map<string, T[]> { 61: const byFile = new Map<string, T[]>() 62: for (const item of items) { 63: const uri = 'uri' in item ? item.uri : item.location.uri 64: const filePath = formatUri(uri, cwd) 65: const existingItems = byFile.get(filePath) 66: if (existingItems) { 67: existingItems.push(item) 68: } else { 69: byFile.set(filePath, [item]) 70: } 71: } 72: return byFile 73: } 74: function formatLocation(location: Location, cwd?: string): string { 75: const filePath = formatUri(location.uri, cwd) 76: const line = location.range.start.line + 1 77: const character = location.range.start.character + 1 78: return `${filePath}:${line}:${character}` 79: } 80: function locationLinkToLocation(link: LocationLink): Location { 81: return { 82: uri: link.targetUri, 83: range: link.targetSelectionRange || link.targetRange, 84: } 85: } 86: function isLocationLink(item: Location | LocationLink): item is LocationLink { 87: return 'targetUri' in item 88: } 89: export function formatGoToDefinitionResult( 90: result: Location | Location[] | LocationLink | LocationLink[] | null, 91: cwd?: string, 92: ): string { 93: if (!result) { 94: return 'No definition found. This may occur if the cursor is not on a symbol, or if the definition is in an external library not indexed by the LSP server.' 95: } 96: if (Array.isArray(result)) { 97: const locations: Location[] = result.map(item => 98: isLocationLink(item) ? locationLinkToLocation(item) : item, 99: ) 100: const invalidLocations = locations.filter(loc => !loc || !loc.uri) 101: if (invalidLocations.length > 0) { 102: logForDebugging( 103: `formatGoToDefinitionResult: Filtering out ${invalidLocations.length} invalid location(s) - this should have been caught earlier`, 104: { level: 'warn' }, 105: ) 106: } 107: const validLocations = locations.filter(loc => loc && loc.uri) 108: if (validLocations.length === 0) { 109: return 'No definition found. This may occur if the cursor is not on a symbol, or if the definition is in an external library not indexed by the LSP server.' 110: } 111: if (validLocations.length === 1) { 112: return `Defined in ${formatLocation(validLocations[0]!, cwd)}` 113: } 114: const locationList = validLocations 115: .map(loc => ` ${formatLocation(loc, cwd)}`) 116: .join('\n') 117: return `Found ${validLocations.length} definitions:\n${locationList}` 118: } 119: const location = isLocationLink(result) 120: ? locationLinkToLocation(result) 121: : result 122: return `Defined in ${formatLocation(location, cwd)}` 123: } 124: export function formatFindReferencesResult( 125: result: Location[] | null, 126: cwd?: string, 127: ): string { 128: if (!result || result.length === 0) { 129: return 'No references found. This may occur if the symbol has no usages, or if the LSP server has not fully indexed the workspace.' 130: } 131: const invalidLocations = result.filter(loc => !loc || !loc.uri) 132: if (invalidLocations.length > 0) { 133: logForDebugging( 134: `formatFindReferencesResult: Filtering out ${invalidLocations.length} invalid location(s) - this should have been caught earlier`, 135: { level: 'warn' }, 136: ) 137: } 138: const validLocations = result.filter(loc => loc && loc.uri) 139: if (validLocations.length === 0) { 140: return 'No references found. This may occur if the symbol has no usages, or if the LSP server has not fully indexed the workspace.' 141: } 142: if (validLocations.length === 1) { 143: return `Found 1 reference:\n ${formatLocation(validLocations[0]!, cwd)}` 144: } 145: const byFile = groupByFile(validLocations, cwd) 146: const lines: string[] = [ 147: `Found ${validLocations.length} references across ${byFile.size} files:`, 148: ] 149: for (const [filePath, locations] of byFile) { 150: lines.push(`\n${filePath}:`) 151: for (const loc of locations) { 152: const line = loc.range.start.line + 1 153: const character = loc.range.start.character + 1 154: lines.push(` Line ${line}:${character}`) 155: } 156: } 157: return lines.join('\n') 158: } 159: function extractMarkupText( 160: contents: MarkupContent | MarkedString | MarkedString[], 161: ): string { 162: if (Array.isArray(contents)) { 163: return contents 164: .map(item => { 165: if (typeof item === 'string') { 166: return item 167: } 168: return item.value 169: }) 170: .join('\n\n') 171: } 172: if (typeof contents === 'string') { 173: return contents 174: } 175: if ('kind' in contents) { 176: return contents.value 177: } 178: return contents.value 179: } 180: export function formatHoverResult(result: Hover | null, _cwd?: string): string { 181: if (!result) { 182: return 'No hover information available. This may occur if the cursor is not on a symbol, or if the LSP server has not fully indexed the file.' 183: } 184: const content = extractMarkupText(result.contents) 185: if (result.range) { 186: const line = result.range.start.line + 1 187: const character = result.range.start.character + 1 188: return `Hover info at ${line}:${character}:\n\n${content}` 189: } 190: return content 191: } 192: function symbolKindToString(kind: SymbolKind): string { 193: const kinds: Record<SymbolKind, string> = { 194: [1]: 'File', 195: [2]: 'Module', 196: [3]: 'Namespace', 197: [4]: 'Package', 198: [5]: 'Class', 199: [6]: 'Method', 200: [7]: 'Property', 201: [8]: 'Field', 202: [9]: 'Constructor', 203: [10]: 'Enum', 204: [11]: 'Interface', 205: [12]: 'Function', 206: [13]: 'Variable', 207: [14]: 'Constant', 208: [15]: 'String', 209: [16]: 'Number', 210: [17]: 'Boolean', 211: [18]: 'Array', 212: [19]: 'Object', 213: [20]: 'Key', 214: [21]: 'Null', 215: [22]: 'EnumMember', 216: [23]: 'Struct', 217: [24]: 'Event', 218: [25]: 'Operator', 219: [26]: 'TypeParameter', 220: } 221: return kinds[kind] || 'Unknown' 222: } 223: function formatDocumentSymbolNode( 224: symbol: DocumentSymbol, 225: indent: number = 0, 226: ): string[] { 227: const lines: string[] = [] 228: const prefix = ' '.repeat(indent) 229: const kind = symbolKindToString(symbol.kind) 230: let line = `${prefix}${symbol.name} (${kind})` 231: if (symbol.detail) { 232: line += ` ${symbol.detail}` 233: } 234: const symbolLine = symbol.range.start.line + 1 235: line += ` - Line ${symbolLine}` 236: lines.push(line) 237: if (symbol.children && symbol.children.length > 0) { 238: for (const child of symbol.children) { 239: lines.push(...formatDocumentSymbolNode(child, indent + 1)) 240: } 241: } 242: return lines 243: } 244: export function formatDocumentSymbolResult( 245: result: DocumentSymbol[] | SymbolInformation[] | null, 246: cwd?: string, 247: ): string { 248: if (!result || result.length === 0) { 249: return 'No symbols found in document. This may occur if the file is empty, not supported by the LSP server, or if the server has not fully indexed the file.' 250: } 251: const firstSymbol = result[0] 252: const isSymbolInformation = firstSymbol && 'location' in firstSymbol 253: if (isSymbolInformation) { 254: return formatWorkspaceSymbolResult(result as SymbolInformation[], cwd) 255: } 256: const lines: string[] = ['Document symbols:'] 257: for (const symbol of result as DocumentSymbol[]) { 258: lines.push(...formatDocumentSymbolNode(symbol)) 259: } 260: return lines.join('\n') 261: } 262: export function formatWorkspaceSymbolResult( 263: result: SymbolInformation[] | null, 264: cwd?: string, 265: ): string { 266: if (!result || result.length === 0) { 267: return 'No symbols found in workspace. This may occur if the workspace is empty, or if the LSP server has not finished indexing the project.' 268: } 269: const invalidSymbols = result.filter( 270: sym => !sym || !sym.location || !sym.location.uri, 271: ) 272: if (invalidSymbols.length > 0) { 273: logForDebugging( 274: `formatWorkspaceSymbolResult: Filtering out ${invalidSymbols.length} invalid symbol(s) - this should have been caught earlier`, 275: { level: 'warn' }, 276: ) 277: } 278: const validSymbols = result.filter( 279: sym => sym && sym.location && sym.location.uri, 280: ) 281: if (validSymbols.length === 0) { 282: return 'No symbols found in workspace. This may occur if the workspace is empty, or if the LSP server has not finished indexing the project.' 283: } 284: const lines: string[] = [ 285: `Found ${validSymbols.length} ${plural(validSymbols.length, 'symbol')} in workspace:`, 286: ] 287: const byFile = groupByFile(validSymbols, cwd) 288: for (const [filePath, symbols] of byFile) { 289: lines.push(`\n${filePath}:`) 290: for (const symbol of symbols) { 291: const kind = symbolKindToString(symbol.kind) 292: const line = symbol.location.range.start.line + 1 293: let symbolLine = ` ${symbol.name} (${kind}) - Line ${line}` 294: if (symbol.containerName) { 295: symbolLine += ` in ${symbol.containerName}` 296: } 297: lines.push(symbolLine) 298: } 299: } 300: return lines.join('\n') 301: } 302: function formatCallHierarchyItem( 303: item: CallHierarchyItem, 304: cwd?: string, 305: ): string { 306: if (!item.uri) { 307: logForDebugging( 308: 'formatCallHierarchyItem: CallHierarchyItem has undefined URI', 309: { level: 'warn' }, 310: ) 311: return `${item.name} (${symbolKindToString(item.kind)}) - <unknown location>` 312: } 313: const filePath = formatUri(item.uri, cwd) 314: const line = item.range.start.line + 1 315: const kind = symbolKindToString(item.kind) 316: let result = `${item.name} (${kind}) - ${filePath}:${line}` 317: if (item.detail) { 318: result += ` [${item.detail}]` 319: } 320: return result 321: } 322: export function formatPrepareCallHierarchyResult( 323: result: CallHierarchyItem[] | null, 324: cwd?: string, 325: ): string { 326: if (!result || result.length === 0) { 327: return 'No call hierarchy item found at this position' 328: } 329: if (result.length === 1) { 330: return `Call hierarchy item: ${formatCallHierarchyItem(result[0]!, cwd)}` 331: } 332: const lines = [`Found ${result.length} call hierarchy items:`] 333: for (const item of result) { 334: lines.push(` ${formatCallHierarchyItem(item, cwd)}`) 335: } 336: return lines.join('\n') 337: } 338: export function formatIncomingCallsResult( 339: result: CallHierarchyIncomingCall[] | null, 340: cwd?: string, 341: ): string { 342: if (!result || result.length === 0) { 343: return 'No incoming calls found (nothing calls this function)' 344: } 345: const lines = [ 346: `Found ${result.length} incoming ${plural(result.length, 'call')}:`, 347: ] 348: const byFile = new Map<string, CallHierarchyIncomingCall[]>() 349: for (const call of result) { 350: if (!call.from) { 351: logForDebugging( 352: 'formatIncomingCallsResult: CallHierarchyIncomingCall has undefined from field', 353: { level: 'warn' }, 354: ) 355: continue 356: } 357: const filePath = formatUri(call.from.uri, cwd) 358: const existing = byFile.get(filePath) 359: if (existing) { 360: existing.push(call) 361: } else { 362: byFile.set(filePath, [call]) 363: } 364: } 365: for (const [filePath, calls] of byFile) { 366: lines.push(`\n${filePath}:`) 367: for (const call of calls) { 368: if (!call.from) { 369: continue 370: } 371: const kind = symbolKindToString(call.from.kind) 372: const line = call.from.range.start.line + 1 373: let callLine = ` ${call.from.name} (${kind}) - Line ${line}` 374: if (call.fromRanges && call.fromRanges.length > 0) { 375: const callSites = call.fromRanges 376: .map(r => `${r.start.line + 1}:${r.start.character + 1}`) 377: .join(', ') 378: callLine += ` [calls at: ${callSites}]` 379: } 380: lines.push(callLine) 381: } 382: } 383: return lines.join('\n') 384: } 385: export function formatOutgoingCallsResult( 386: result: CallHierarchyOutgoingCall[] | null, 387: cwd?: string, 388: ): string { 389: if (!result || result.length === 0) { 390: return 'No outgoing calls found (this function calls nothing)' 391: } 392: const lines = [ 393: `Found ${result.length} outgoing ${plural(result.length, 'call')}:`, 394: ] 395: const byFile = new Map<string, CallHierarchyOutgoingCall[]>() 396: for (const call of result) { 397: if (!call.to) { 398: logForDebugging( 399: 'formatOutgoingCallsResult: CallHierarchyOutgoingCall has undefined to field', 400: { level: 'warn' }, 401: ) 402: continue 403: } 404: const filePath = formatUri(call.to.uri, cwd) 405: const existing = byFile.get(filePath) 406: if (existing) { 407: existing.push(call) 408: } else { 409: byFile.set(filePath, [call]) 410: } 411: } 412: for (const [filePath, calls] of byFile) { 413: lines.push(`\n${filePath}:`) 414: for (const call of calls) { 415: if (!call.to) { 416: continue 417: } 418: const kind = symbolKindToString(call.to.kind) 419: const line = call.to.range.start.line + 1 420: let callLine = ` ${call.to.name} (${kind}) - Line ${line}` 421: if (call.fromRanges && call.fromRanges.length > 0) { 422: const callSites = call.fromRanges 423: .map(r => `${r.start.line + 1}:${r.start.character + 1}`) 424: .join(', ') 425: callLine += ` [called from: ${callSites}]` 426: } 427: lines.push(callLine) 428: } 429: } 430: return lines.join('\n') 431: }

File: src/tools/LSPTool/LSPTool.ts

typescript 1: import { open } from 'fs/promises' 2: import * as path from 'path' 3: import { pathToFileURL } from 'url' 4: import type { 5: CallHierarchyIncomingCall, 6: CallHierarchyItem, 7: CallHierarchyOutgoingCall, 8: DocumentSymbol, 9: Hover, 10: Location, 11: LocationLink, 12: SymbolInformation, 13: } from 'vscode-languageserver-types' 14: import { z } from 'zod/v4' 15: import { 16: getInitializationStatus, 17: getLspServerManager, 18: isLspConnected, 19: waitForInitialization, 20: } from '../../services/lsp/manager.js' 21: import type { ValidationResult } from '../../Tool.js' 22: import { buildTool, type ToolDef } from '../../Tool.js' 23: import { uniq } from '../../utils/array.js' 24: import { getCwd } from '../../utils/cwd.js' 25: import { logForDebugging } from '../../utils/debug.js' 26: import { isENOENT, toError } from '../../utils/errors.js' 27: import { execFileNoThrowWithCwd } from '../../utils/execFileNoThrow.js' 28: import { getFsImplementation } from '../../utils/fsOperations.js' 29: import { lazySchema } from '../../utils/lazySchema.js' 30: import { logError } from '../../utils/log.js' 31: import { expandPath } from '../../utils/path.js' 32: import { checkReadPermissionForTool } from '../../utils/permissions/filesystem.js' 33: import type { PermissionDecision } from '../../utils/permissions/PermissionResult.js' 34: import { 35: formatDocumentSymbolResult, 36: formatFindReferencesResult, 37: formatGoToDefinitionResult, 38: formatHoverResult, 39: formatIncomingCallsResult, 40: formatOutgoingCallsResult, 41: formatPrepareCallHierarchyResult, 42: formatWorkspaceSymbolResult, 43: } from './formatters.js' 44: import { DESCRIPTION, LSP_TOOL_NAME } from './prompt.js' 45: import { lspToolInputSchema } from './schemas.js' 46: import { 47: renderToolResultMessage, 48: renderToolUseErrorMessage, 49: renderToolUseMessage, 50: userFacingName, 51: } from './UI.js' 52: const MAX_LSP_FILE_SIZE_BYTES = 10_000_000 53: const inputSchema = lazySchema(() => 54: z.strictObject({ 55: operation: z 56: .enum([ 57: 'goToDefinition', 58: 'findReferences', 59: 'hover', 60: 'documentSymbol', 61: 'workspaceSymbol', 62: 'goToImplementation', 63: 'prepareCallHierarchy', 64: 'incomingCalls', 65: 'outgoingCalls', 66: ]) 67: .describe('The LSP operation to perform'), 68: filePath: z.string().describe('The absolute or relative path to the file'), 69: line: z 70: .number() 71: .int() 72: .positive() 73: .describe('The line number (1-based, as shown in editors)'), 74: character: z 75: .number() 76: .int() 77: .positive() 78: .describe('The character offset (1-based, as shown in editors)'), 79: }), 80: ) 81: type InputSchema = ReturnType<typeof inputSchema> 82: const outputSchema = lazySchema(() => 83: z.object({ 84: operation: z 85: .enum([ 86: 'goToDefinition', 87: 'findReferences', 88: 'hover', 89: 'documentSymbol', 90: 'workspaceSymbol', 91: 'goToImplementation', 92: 'prepareCallHierarchy', 93: 'incomingCalls', 94: 'outgoingCalls', 95: ]) 96: .describe('The LSP operation that was performed'), 97: result: z.string().describe('The formatted result of the LSP operation'), 98: filePath: z 99: .string() 100: .describe('The file path the operation was performed on'), 101: resultCount: z 102: .number() 103: .int() 104: .nonnegative() 105: .optional() 106: .describe('Number of results (definitions, references, symbols)'), 107: fileCount: z 108: .number() 109: .int() 110: .nonnegative() 111: .optional() 112: .describe('Number of files containing results'), 113: }), 114: ) 115: type OutputSchema = ReturnType<typeof outputSchema> 116: export type Output = z.infer<OutputSchema> 117: export type Input = z.infer<InputSchema> 118: export const LSPTool = buildTool({ 119: name: LSP_TOOL_NAME, 120: searchHint: 'code intelligence (definitions, references, symbols, hover)', 121: maxResultSizeChars: 100_000, 122: isLsp: true, 123: async description() { 124: return DESCRIPTION 125: }, 126: userFacingName, 127: shouldDefer: true, 128: isEnabled() { 129: return isLspConnected() 130: }, 131: get inputSchema(): InputSchema { 132: return inputSchema() 133: }, 134: get outputSchema(): OutputSchema { 135: return outputSchema() 136: }, 137: isConcurrencySafe() { 138: return true 139: }, 140: isReadOnly() { 141: return true 142: }, 143: getPath({ filePath }): string { 144: return expandPath(filePath) 145: }, 146: async validateInput(input: Input): Promise<ValidationResult> { 147: const parseResult = lspToolInputSchema().safeParse(input) 148: if (!parseResult.success) { 149: return { 150: result: false, 151: message: `Invalid input: ${parseResult.error.message}`, 152: errorCode: 3, 153: } 154: } 155: const fs = getFsImplementation() 156: const absolutePath = expandPath(input.filePath) 157: if (absolutePath.startsWith('\\\\') || absolutePath.startsWith(' 158: return { result: true } 159: } 160: let stats 161: try { 162: stats = await fs.stat(absolutePath) 163: } catch (error) { 164: if (isENOENT(error)) { 165: return { 166: result: false, 167: message: `File does not exist: ${input.filePath}`, 168: errorCode: 1, 169: } 170: } 171: const err = toError(error) 172: logError( 173: new Error( 174: `Failed to access file stats for LSP operation on ${input.filePath}: ${err.message}`, 175: ), 176: ) 177: return { 178: result: false, 179: message: `Cannot access file: ${input.filePath}. ${err.message}`, 180: errorCode: 4, 181: } 182: } 183: if (!stats.isFile()) { 184: return { 185: result: false, 186: message: `Path is not a file: ${input.filePath}`, 187: errorCode: 2, 188: } 189: } 190: return { result: true } 191: }, 192: async checkPermissions(input, context): Promise<PermissionDecision> { 193: const appState = context.getAppState() 194: return checkReadPermissionForTool( 195: LSPTool, 196: input, 197: appState.toolPermissionContext, 198: ) 199: }, 200: async prompt() { 201: return DESCRIPTION 202: }, 203: renderToolUseMessage, 204: renderToolUseErrorMessage, 205: renderToolResultMessage, 206: async call(input: Input, _context) { 207: const absolutePath = expandPath(input.filePath) 208: const cwd = getCwd() 209: const status = getInitializationStatus() 210: if (status.status === 'pending') { 211: await waitForInitialization() 212: } 213: const manager = getLspServerManager() 214: if (!manager) { 215: logError( 216: new Error('LSP server manager not initialized when tool was called'), 217: ) 218: const output: Output = { 219: operation: input.operation, 220: result: 221: 'LSP server manager not initialized. This may indicate a startup issue.', 222: filePath: input.filePath, 223: } 224: return { 225: data: output, 226: } 227: } 228: const { method, params } = getMethodAndParams(input, absolutePath) 229: try { 230: if (!manager.isFileOpen(absolutePath)) { 231: const handle = await open(absolutePath, 'r') 232: try { 233: const stats = await handle.stat() 234: if (stats.size > MAX_LSP_FILE_SIZE_BYTES) { 235: const output: Output = { 236: operation: input.operation, 237: result: `File too large for LSP analysis (${Math.ceil(stats.size / 1_000_000)}MB exceeds 10MB limit)`, 238: filePath: input.filePath, 239: } 240: return { data: output } 241: } 242: const fileContent = await handle.readFile({ encoding: 'utf-8' }) 243: await manager.openFile(absolutePath, fileContent) 244: } finally { 245: await handle.close() 246: } 247: } 248: let result = await manager.sendRequest(absolutePath, method, params) 249: if (result === undefined) { 250: logForDebugging( 251: `No LSP server available for file type ${path.extname(absolutePath)} for operation ${input.operation} on file ${input.filePath}`, 252: ) 253: const output: Output = { 254: operation: input.operation, 255: result: `No LSP server available for file type: ${path.extname(absolutePath)}`, 256: filePath: input.filePath, 257: } 258: return { 259: data: output, 260: } 261: } 262: if ( 263: input.operation === 'incomingCalls' || 264: input.operation === 'outgoingCalls' 265: ) { 266: const callItems = result as CallHierarchyItem[] 267: if (!callItems || callItems.length === 0) { 268: const output: Output = { 269: operation: input.operation, 270: result: 'No call hierarchy item found at this position', 271: filePath: input.filePath, 272: resultCount: 0, 273: fileCount: 0, 274: } 275: return { data: output } 276: } 277: const callMethod = 278: input.operation === 'incomingCalls' 279: ? 'callHierarchy/incomingCalls' 280: : 'callHierarchy/outgoingCalls' 281: result = await manager.sendRequest(absolutePath, callMethod, { 282: item: callItems[0], 283: }) 284: if (result === undefined) { 285: logForDebugging( 286: `LSP server returned undefined for ${callMethod} on ${input.filePath}`, 287: ) 288: } 289: } 290: if ( 291: result && 292: Array.isArray(result) && 293: (input.operation === 'findReferences' || 294: input.operation === 'goToDefinition' || 295: input.operation === 'goToImplementation' || 296: input.operation === 'workspaceSymbol') 297: ) { 298: if (input.operation === 'workspaceSymbol') { 299: const symbols = result as SymbolInformation[] 300: const locations = symbols 301: .filter(s => s?.location?.uri) 302: .map(s => s.location) 303: const filteredLocations = await filterGitIgnoredLocations( 304: locations, 305: cwd, 306: ) 307: const filteredUris = new Set(filteredLocations.map(l => l.uri)) 308: result = symbols.filter( 309: s => !s?.location?.uri || filteredUris.has(s.location.uri), 310: ) 311: } else { 312: const locations = (result as (Location | LocationLink)[]).map( 313: toLocation, 314: ) 315: const filteredLocations = await filterGitIgnoredLocations( 316: locations, 317: cwd, 318: ) 319: const filteredUris = new Set(filteredLocations.map(l => l.uri)) 320: result = (result as (Location | LocationLink)[]).filter(item => { 321: const loc = toLocation(item) 322: return !loc.uri || filteredUris.has(loc.uri) 323: }) 324: } 325: } 326: const { formatted, resultCount, fileCount } = formatResult( 327: input.operation, 328: result, 329: cwd, 330: ) 331: const output: Output = { 332: operation: input.operation, 333: result: formatted, 334: filePath: input.filePath, 335: resultCount, 336: fileCount, 337: } 338: return { 339: data: output, 340: } 341: } catch (error) { 342: const err = toError(error) 343: const errorMessage = err.message 344: logError( 345: new Error( 346: `LSP tool request failed for ${input.operation} on ${input.filePath}: ${errorMessage}`, 347: ), 348: ) 349: const output: Output = { 350: operation: input.operation, 351: result: `Error performing ${input.operation}: ${errorMessage}`, 352: filePath: input.filePath, 353: } 354: return { 355: data: output, 356: } 357: } 358: }, 359: mapToolResultToToolResultBlockParam(output, toolUseID) { 360: return { 361: tool_use_id: toolUseID, 362: type: 'tool_result', 363: content: output.result, 364: } 365: }, 366: } satisfies ToolDef<InputSchema, Output>) 367: function getMethodAndParams( 368: input: Input, 369: absolutePath: string, 370: ): { method: string; params: unknown } { 371: const uri = pathToFileURL(absolutePath).href 372: const position = { 373: line: input.line - 1, 374: character: input.character - 1, 375: } 376: switch (input.operation) { 377: case 'goToDefinition': 378: return { 379: method: 'textDocument/definition', 380: params: { 381: textDocument: { uri }, 382: position, 383: }, 384: } 385: case 'findReferences': 386: return { 387: method: 'textDocument/references', 388: params: { 389: textDocument: { uri }, 390: position, 391: context: { includeDeclaration: true }, 392: }, 393: } 394: case 'hover': 395: return { 396: method: 'textDocument/hover', 397: params: { 398: textDocument: { uri }, 399: position, 400: }, 401: } 402: case 'documentSymbol': 403: return { 404: method: 'textDocument/documentSymbol', 405: params: { 406: textDocument: { uri }, 407: }, 408: } 409: case 'workspaceSymbol': 410: return { 411: method: 'workspace/symbol', 412: params: { 413: query: '', // Empty query returns all symbols 414: }, 415: } 416: case 'goToImplementation': 417: return { 418: method: 'textDocument/implementation', 419: params: { 420: textDocument: { uri }, 421: position, 422: }, 423: } 424: case 'prepareCallHierarchy': 425: return { 426: method: 'textDocument/prepareCallHierarchy', 427: params: { 428: textDocument: { uri }, 429: position, 430: }, 431: } 432: case 'incomingCalls': 433: return { 434: method: 'textDocument/prepareCallHierarchy', 435: params: { 436: textDocument: { uri }, 437: position, 438: }, 439: } 440: case 'outgoingCalls': 441: return { 442: method: 'textDocument/prepareCallHierarchy', 443: params: { 444: textDocument: { uri }, 445: position, 446: }, 447: } 448: } 449: } 450: function countSymbols(symbols: DocumentSymbol[]): number { 451: let count = symbols.length 452: for (const symbol of symbols) { 453: if (symbol.children && symbol.children.length > 0) { 454: count += countSymbols(symbol.children) 455: } 456: } 457: return count 458: } 459: function countUniqueFiles(locations: Location[]): number { 460: return new Set(locations.map(loc => loc.uri)).size 461: } 462: function uriToFilePath(uri: string): string { 463: let filePath = uri.replace(/^file:\/\//, '') 464: // On Windows, file:///C:/path becomes /C:/path — strip the leading slash 465: if (/^\/[A-Za-z]:/.test(filePath)) { 466: filePath = filePath.slice(1) 467: } 468: try { 469: filePath = decodeURIComponent(filePath) 470: } catch { 471: // Use un-decoded path if malformed 472: } 473: return filePath 474: } 475: /** 476: * Filters out locations whose file paths are gitignored. 477: * Uses `git check-ignore` with batched path arguments for efficiency. 478: */ 479: async function filterGitIgnoredLocations<T extends Location>( 480: locations: T[], 481: cwd: string, 482: ): Promise<T[]> { 483: if (locations.length === 0) { 484: return locations 485: } 486: // Collect unique file paths from URIs 487: const uriToPath = new Map<string, string>() 488: for (const loc of locations) { 489: if (loc.uri && !uriToPath.has(loc.uri)) { 490: uriToPath.set(loc.uri, uriToFilePath(loc.uri)) 491: } 492: } 493: const uniquePaths = uniq(uriToPath.values()) 494: if (uniquePaths.length === 0) { 495: return locations 496: } 497: // Batch check paths with git check-ignore 498: // Exit code 0 = at least one path is ignored, 1 = none ignored, 128 = not a git repo 499: const ignoredPaths = new Set<string>() 500: const BATCH_SIZE = 50 501: for (let i = 0; i < uniquePaths.length; i += BATCH_SIZE) { 502: const batch = uniquePaths.slice(i, i + BATCH_SIZE) 503: const result = await execFileNoThrowWithCwd( 504: 'git', 505: ['check-ignore', ...batch], 506: { 507: cwd, 508: preserveOutputOnError: false, 509: timeout: 5_000, 510: }, 511: ) 512: if (result.code === 0 && result.stdout) { 513: for (const line of result.stdout.split('\n')) { 514: const trimmed = line.trim() 515: if (trimmed) { 516: ignoredPaths.add(trimmed) 517: } 518: } 519: } 520: } 521: if (ignoredPaths.size === 0) { 522: return locations 523: } 524: return locations.filter(loc => { 525: const filePath = uriToPath.get(loc.uri) 526: return !filePath || !ignoredPaths.has(filePath) 527: }) 528: } 529: function isLocationLink(item: Location | LocationLink): item is LocationLink { 530: return 'targetUri' in item 531: } 532: function toLocation(item: Location | LocationLink): Location { 533: if (isLocationLink(item)) { 534: return { 535: uri: item.targetUri, 536: range: item.targetSelectionRange || item.targetRange, 537: } 538: } 539: return item 540: } 541: function formatResult( 542: operation: Input['operation'], 543: result: unknown, 544: cwd: string, 545: ): { formatted: string; resultCount: number; fileCount: number } { 546: switch (operation) { 547: case 'goToDefinition': { 548: const rawResults = Array.isArray(result) 549: ? result 550: : result 551: ? [result as Location | LocationLink] 552: : [] 553: const locations = rawResults.map(toLocation) 554: const invalidLocations = locations.filter(loc => !loc || !loc.uri) 555: if (invalidLocations.length > 0) { 556: logError( 557: new Error( 558: `LSP server returned ${invalidLocations.length} location(s) with undefined URI for goToDefinition on ${cwd}. ` + 559: `This indicates malformed data from the LSP server.`, 560: ), 561: ) 562: } 563: const validLocations = locations.filter(loc => loc && loc.uri) 564: return { 565: formatted: formatGoToDefinitionResult( 566: result as 567: | Location 568: | Location[] 569: | LocationLink 570: | LocationLink[] 571: | null, 572: cwd, 573: ), 574: resultCount: validLocations.length, 575: fileCount: countUniqueFiles(validLocations), 576: } 577: } 578: case 'findReferences': { 579: const locations = (result as Location[]) || [] 580: const invalidLocations = locations.filter(loc => !loc || !loc.uri) 581: if (invalidLocations.length > 0) { 582: logError( 583: new Error( 584: `LSP server returned ${invalidLocations.length} location(s) with undefined URI for findReferences on ${cwd}. ` + 585: `This indicates malformed data from the LSP server.`, 586: ), 587: ) 588: } 589: const validLocations = locations.filter(loc => loc && loc.uri) 590: return { 591: formatted: formatFindReferencesResult(result as Location[] | null, cwd), 592: resultCount: validLocations.length, 593: fileCount: countUniqueFiles(validLocations), 594: } 595: } 596: case 'hover': { 597: return { 598: formatted: formatHoverResult(result as Hover | null, cwd), 599: resultCount: result ? 1 : 0, 600: fileCount: result ? 1 : 0, 601: } 602: } 603: case 'documentSymbol': { 604: const symbols = (result as (DocumentSymbol | SymbolInformation)[]) || [] 605: const isDocumentSymbol = 606: symbols.length > 0 && symbols[0] && 'range' in symbols[0] 607: const count = isDocumentSymbol 608: ? countSymbols(symbols as DocumentSymbol[]) 609: : symbols.length 610: return { 611: formatted: formatDocumentSymbolResult( 612: result as (DocumentSymbol[] | SymbolInformation[]) | null, 613: cwd, 614: ), 615: resultCount: count, 616: fileCount: symbols.length > 0 ? 1 : 0, 617: } 618: } 619: case 'workspaceSymbol': { 620: const symbols = (result as SymbolInformation[]) || [] 621: const invalidSymbols = symbols.filter( 622: sym => !sym || !sym.location || !sym.location.uri, 623: ) 624: if (invalidSymbols.length > 0) { 625: logError( 626: new Error( 627: `LSP server returned ${invalidSymbols.length} symbol(s) with undefined location URI for workspaceSymbol on ${cwd}. ` + 628: `This indicates malformed data from the LSP server.`, 629: ), 630: ) 631: } 632: const validSymbols = symbols.filter( 633: sym => sym && sym.location && sym.location.uri, 634: ) 635: const locations = validSymbols.map(s => s.location) 636: return { 637: formatted: formatWorkspaceSymbolResult( 638: result as SymbolInformation[] | null, 639: cwd, 640: ), 641: resultCount: validSymbols.length, 642: fileCount: countUniqueFiles(locations), 643: } 644: } 645: case 'goToImplementation': { 646: const rawResults = Array.isArray(result) 647: ? result 648: : result 649: ? [result as Location | LocationLink] 650: : [] 651: const locations = rawResults.map(toLocation) 652: const invalidLocations = locations.filter(loc => !loc || !loc.uri) 653: if (invalidLocations.length > 0) { 654: logError( 655: new Error( 656: `LSP server returned ${invalidLocations.length} location(s) with undefined URI for goToImplementation on ${cwd}. ` + 657: `This indicates malformed data from the LSP server.`, 658: ), 659: ) 660: } 661: const validLocations = locations.filter(loc => loc && loc.uri) 662: return { 663: formatted: formatGoToDefinitionResult( 664: result as 665: | Location 666: | Location[] 667: | LocationLink 668: | LocationLink[] 669: | null, 670: cwd, 671: ), 672: resultCount: validLocations.length, 673: fileCount: countUniqueFiles(validLocations), 674: } 675: } 676: case 'prepareCallHierarchy': { 677: const items = (result as CallHierarchyItem[]) || [] 678: return { 679: formatted: formatPrepareCallHierarchyResult( 680: result as CallHierarchyItem[] | null, 681: cwd, 682: ), 683: resultCount: items.length, 684: fileCount: items.length > 0 ? countUniqueFilesFromCallItems(items) : 0, 685: } 686: } 687: case 'incomingCalls': { 688: const calls = (result as CallHierarchyIncomingCall[]) || [] 689: return { 690: formatted: formatIncomingCallsResult( 691: result as CallHierarchyIncomingCall[] | null, 692: cwd, 693: ), 694: resultCount: calls.length, 695: fileCount: 696: calls.length > 0 ? countUniqueFilesFromIncomingCalls(calls) : 0, 697: } 698: } 699: case 'outgoingCalls': { 700: const calls = (result as CallHierarchyOutgoingCall[]) || [] 701: return { 702: formatted: formatOutgoingCallsResult( 703: result as CallHierarchyOutgoingCall[] | null, 704: cwd, 705: ), 706: resultCount: calls.length, 707: fileCount: 708: calls.length > 0 ? countUniqueFilesFromOutgoingCalls(calls) : 0, 709: } 710: } 711: } 712: } 713: function countUniqueFilesFromCallItems(items: CallHierarchyItem[]): number { 714: const validUris = items.map(item => item.uri).filter(uri => uri) 715: return new Set(validUris).size 716: } 717: function countUniqueFilesFromIncomingCalls( 718: calls: CallHierarchyIncomingCall[], 719: ): number { 720: const validUris = calls.map(call => call.from?.uri).filter(uri => uri) 721: return new Set(validUris).size 722: } 723: function countUniqueFilesFromOutgoingCalls( 724: calls: CallHierarchyOutgoingCall[], 725: ): number { 726: const validUris = calls.map(call => call.to?.uri).filter(uri => uri) 727: return new Set(validUris).size 728: }

File: src/tools/LSPTool/prompt.ts

typescript 1: export const LSP_TOOL_NAME = 'LSP' as const 2: export const DESCRIPTION = `Interact with Language Server Protocol (LSP) servers to get code intelligence features. 3: Supported operations: 4: - goToDefinition: Find where a symbol is defined 5: - findReferences: Find all references to a symbol 6: - hover: Get hover information (documentation, type info) for a symbol 7: - documentSymbol: Get all symbols (functions, classes, variables) in a document 8: - workspaceSymbol: Search for symbols across the entire workspace 9: - goToImplementation: Find implementations of an interface or abstract method 10: - prepareCallHierarchy: Get call hierarchy item at a position (functions/methods) 11: - incomingCalls: Find all functions/methods that call the function at a position 12: - outgoingCalls: Find all functions/methods called by the function at a position 13: All operations require: 14: - filePath: The file to operate on 15: - line: The line number (1-based, as shown in editors) 16: - character: The character offset (1-based, as shown in editors) 17: Note: LSP servers must be configured for the file type. If no server is available, an error will be returned.`

File: src/tools/LSPTool/schemas.ts

typescript 1: import { z } from 'zod/v4' 2: import { lazySchema } from '../../utils/lazySchema.js' 3: export const lspToolInputSchema = lazySchema(() => { 4: const goToDefinitionSchema = z.strictObject({ 5: operation: z.literal('goToDefinition'), 6: filePath: z.string().describe('The absolute or relative path to the file'), 7: line: z 8: .number() 9: .int() 10: .positive() 11: .describe('The line number (1-based, as shown in editors)'), 12: character: z 13: .number() 14: .int() 15: .positive() 16: .describe('The character offset (1-based, as shown in editors)'), 17: }) 18: const findReferencesSchema = z.strictObject({ 19: operation: z.literal('findReferences'), 20: filePath: z.string().describe('The absolute or relative path to the file'), 21: line: z 22: .number() 23: .int() 24: .positive() 25: .describe('The line number (1-based, as shown in editors)'), 26: character: z 27: .number() 28: .int() 29: .positive() 30: .describe('The character offset (1-based, as shown in editors)'), 31: }) 32: const hoverSchema = z.strictObject({ 33: operation: z.literal('hover'), 34: filePath: z.string().describe('The absolute or relative path to the file'), 35: line: z 36: .number() 37: .int() 38: .positive() 39: .describe('The line number (1-based, as shown in editors)'), 40: character: z 41: .number() 42: .int() 43: .positive() 44: .describe('The character offset (1-based, as shown in editors)'), 45: }) 46: const documentSymbolSchema = z.strictObject({ 47: operation: z.literal('documentSymbol'), 48: filePath: z.string().describe('The absolute or relative path to the file'), 49: line: z 50: .number() 51: .int() 52: .positive() 53: .describe('The line number (1-based, as shown in editors)'), 54: character: z 55: .number() 56: .int() 57: .positive() 58: .describe('The character offset (1-based, as shown in editors)'), 59: }) 60: const workspaceSymbolSchema = z.strictObject({ 61: operation: z.literal('workspaceSymbol'), 62: filePath: z.string().describe('The absolute or relative path to the file'), 63: line: z 64: .number() 65: .int() 66: .positive() 67: .describe('The line number (1-based, as shown in editors)'), 68: character: z 69: .number() 70: .int() 71: .positive() 72: .describe('The character offset (1-based, as shown in editors)'), 73: }) 74: const goToImplementationSchema = z.strictObject({ 75: operation: z.literal('goToImplementation'), 76: filePath: z.string().describe('The absolute or relative path to the file'), 77: line: z 78: .number() 79: .int() 80: .positive() 81: .describe('The line number (1-based, as shown in editors)'), 82: character: z 83: .number() 84: .int() 85: .positive() 86: .describe('The character offset (1-based, as shown in editors)'), 87: }) 88: const prepareCallHierarchySchema = z.strictObject({ 89: operation: z.literal('prepareCallHierarchy'), 90: filePath: z.string().describe('The absolute or relative path to the file'), 91: line: z 92: .number() 93: .int() 94: .positive() 95: .describe('The line number (1-based, as shown in editors)'), 96: character: z 97: .number() 98: .int() 99: .positive() 100: .describe('The character offset (1-based, as shown in editors)'), 101: }) 102: const incomingCallsSchema = z.strictObject({ 103: operation: z.literal('incomingCalls'), 104: filePath: z.string().describe('The absolute or relative path to the file'), 105: line: z 106: .number() 107: .int() 108: .positive() 109: .describe('The line number (1-based, as shown in editors)'), 110: character: z 111: .number() 112: .int() 113: .positive() 114: .describe('The character offset (1-based, as shown in editors)'), 115: }) 116: const outgoingCallsSchema = z.strictObject({ 117: operation: z.literal('outgoingCalls'), 118: filePath: z.string().describe('The absolute or relative path to the file'), 119: line: z 120: .number() 121: .int() 122: .positive() 123: .describe('The line number (1-based, as shown in editors)'), 124: character: z 125: .number() 126: .int() 127: .positive() 128: .describe('The character offset (1-based, as shown in editors)'), 129: }) 130: return z.discriminatedUnion('operation', [ 131: goToDefinitionSchema, 132: findReferencesSchema, 133: hoverSchema, 134: documentSymbolSchema, 135: workspaceSymbolSchema, 136: goToImplementationSchema, 137: prepareCallHierarchySchema, 138: incomingCallsSchema, 139: outgoingCallsSchema, 140: ]) 141: }) 142: export type LSPToolInput = z.infer<ReturnType<typeof lspToolInputSchema>> 143: export function isValidLSPOperation( 144: operation: string, 145: ): operation is LSPToolInput['operation'] { 146: return [ 147: 'goToDefinition', 148: 'findReferences', 149: 'hover', 150: 'documentSymbol', 151: 'workspaceSymbol', 152: 'goToImplementation', 153: 'prepareCallHierarchy', 154: 'incomingCalls', 155: 'outgoingCalls', 156: ].includes(operation) 157: }

File: src/tools/LSPTool/symbolContext.ts

typescript 1: import { logForDebugging } from '../../utils/debug.js' 2: import { truncate } from '../../utils/format.js' 3: import { getFsImplementation } from '../../utils/fsOperations.js' 4: import { expandPath } from '../../utils/path.js' 5: const MAX_READ_BYTES = 64 * 1024 6: export function getSymbolAtPosition( 7: filePath: string, 8: line: number, 9: character: number, 10: ): string | null { 11: try { 12: const fs = getFsImplementation() 13: const absolutePath = expandPath(filePath) 14: const { buffer, bytesRead } = fs.readSync(absolutePath, { 15: length: MAX_READ_BYTES, 16: }) 17: const content = buffer.toString('utf-8', 0, bytesRead) 18: const lines = content.split('\n') 19: if (line < 0 || line >= lines.length) { 20: return null 21: } 22: if (bytesRead === MAX_READ_BYTES && line === lines.length - 1) { 23: return null 24: } 25: const lineContent = lines[line] 26: if (!lineContent || character < 0 || character >= lineContent.length) { 27: return null 28: } 29: const symbolPattern = /[\w$'!]+|[+\-*/%&|^~<>=]+/g 30: let match: RegExpExecArray | null 31: while ((match = symbolPattern.exec(lineContent)) !== null) { 32: const start = match.index 33: const end = start + match[0].length 34: // Check if the character position falls within this match 35: if (character >= start && character < end) { 36: const symbol = match[0] 37: // Limit length to 30 characters to avoid overly long symbols 38: return truncate(symbol, 30) 39: } 40: } 41: return null 42: } catch (error) { 43: // Log unexpected errors for debugging (permission issues, encoding problems, etc.) 44: // Use logForDebugging since this is a display enhancement, not a critical error 45: if (error instanceof Error) { 46: logForDebugging( 47: `Symbol extraction failed for ${filePath}:${line}:${character}: ${error.message}`, 48: { level: 'warn' }, 49: ) 50: } 51: return null 52: } 53: }

File: src/tools/LSPTool/UI.tsx

typescript 1: import { c as _c } from "react/compiler-runtime"; 2: import type { ToolResultBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'; 3: import React from 'react'; 4: import { CtrlOToExpand } from '../../components/CtrlOToExpand.js'; 5: import { FallbackToolUseErrorMessage } from '../../components/FallbackToolUseErrorMessage.js'; 6: import { MessageResponse } from '../../components/MessageResponse.js'; 7: import { Box, Text } from '../../ink.js'; 8: import { getDisplayPath } from '../../utils/file.js'; 9: import { extractTag } from '../../utils/messages.js'; 10: import type { Input, Output } from './LSPTool.js'; 11: import { getSymbolAtPosition } from './symbolContext.js'; 12: const OPERATION_LABELS: Record<Input['operation'], { 13: singular: string; 14: plural: string; 15: special?: string; 16: }> = { 17: goToDefinition: { 18: singular: 'definition', 19: plural: 'definitions' 20: }, 21: findReferences: { 22: singular: 'reference', 23: plural: 'references' 24: }, 25: documentSymbol: { 26: singular: 'symbol', 27: plural: 'symbols' 28: }, 29: workspaceSymbol: { 30: singular: 'symbol', 31: plural: 'symbols' 32: }, 33: hover: { 34: singular: 'hover info', 35: plural: 'hover info', 36: special: 'available' 37: }, 38: goToImplementation: { 39: singular: 'implementation', 40: plural: 'implementations' 41: }, 42: prepareCallHierarchy: { 43: singular: 'call item', 44: plural: 'call items' 45: }, 46: incomingCalls: { 47: singular: 'caller', 48: plural: 'callers' 49: }, 50: outgoingCalls: { 51: singular: 'callee', 52: plural: 'callees' 53: } 54: }; 55: function LSPResultSummary(t0) { 56: const $ = _c(24); 57: const { 58: operation, 59: resultCount, 60: fileCount, 61: content, 62: verbose 63: } = t0; 64: let t1; 65: if ($[0] !== operation) { 66: t1 = OPERATION_LABELS[operation] || { 67: singular: "result", 68: plural: "results" 69: }; 70: $[0] = operation; 71: $[1] = t1; 72: } else { 73: t1 = $[1]; 74: } 75: const labelConfig = t1; 76: const countLabel = resultCount === 1 ? labelConfig.singular : labelConfig.plural; 77: let t2; 78: if ($[2] !== countLabel || $[3] !== labelConfig.special || $[4] !== operation || $[5] !== resultCount) { 79: t2 = operation === "hover" && resultCount > 0 && labelConfig.special ? <Text>Hover info {labelConfig.special}</Text> : <Text>Found <Text bold={true}>{resultCount} </Text>{countLabel}</Text>; 80: $[2] = countLabel; 81: $[3] = labelConfig.special; 82: $[4] = operation; 83: $[5] = resultCount; 84: $[6] = t2; 85: } else { 86: t2 = $[6]; 87: } 88: const primaryText = t2; 89: let t3; 90: if ($[7] !== fileCount) { 91: t3 = fileCount > 1 ? <Text>{" "}across <Text bold={true}>{fileCount} </Text>files</Text> : null; 92: $[7] = fileCount; 93: $[8] = t3; 94: } else { 95: t3 = $[8]; 96: } 97: const secondaryText = t3; 98: if (verbose) { 99: let t4; 100: if ($[9] === Symbol.for("react.memo_cache_sentinel")) { 101: t4 = <Text dimColor={true}>  ⎿  </Text>; 102: $[9] = t4; 103: } else { 104: t4 = $[9]; 105: } 106: let t5; 107: if ($[10] !== primaryText || $[11] !== secondaryText) { 108: t5 = <Box flexDirection="row"><Text>{t4}{primaryText}{secondaryText}</Text></Box>; 109: $[10] = primaryText; 110: $[11] = secondaryText; 111: $[12] = t5; 112: } else { 113: t5 = $[12]; 114: } 115: let t6; 116: if ($[13] !== content) { 117: t6 = <Box marginLeft={5}><Text>{content}</Text></Box>; 118: $[13] = content; 119: $[14] = t6; 120: } else { 121: t6 = $[14]; 122: } 123: let t7; 124: if ($[15] !== t5 || $[16] !== t6) { 125: t7 = <Box flexDirection="column">{t5}{t6}</Box>; 126: $[15] = t5; 127: $[16] = t6; 128: $[17] = t7; 129: } else { 130: t7 = $[17]; 131: } 132: return t7; 133: } 134: let t4; 135: if ($[18] !== resultCount) { 136: t4 = resultCount > 0 && <CtrlOToExpand />; 137: $[18] = resultCount; 138: $[19] = t4; 139: } else { 140: t4 = $[19]; 141: } 142: let t5; 143: if ($[20] !== primaryText || $[21] !== secondaryText || $[22] !== t4) { 144: t5 = <MessageResponse height={1}><Text>{primaryText}{secondaryText} {t4}</Text></MessageResponse>; 145: $[20] = primaryText; 146: $[21] = secondaryText; 147: $[22] = t4; 148: $[23] = t5; 149: } else { 150: t5 = $[23]; 151: } 152: return t5; 153: } 154: export function userFacingName(): string { 155: return 'LSP'; 156: } 157: export function renderToolUseMessage(input: Partial<Input>, { 158: verbose 159: }: { 160: verbose: boolean; 161: }): React.ReactNode { 162: if (!input.operation) { 163: return null; 164: } 165: const parts: string[] = []; 166: if ((input.operation === 'goToDefinition' || input.operation === 'findReferences' || input.operation === 'hover' || input.operation === 'goToImplementation') && input.filePath && input.line !== undefined && input.character !== undefined) { 167: const symbol = getSymbolAtPosition(input.filePath, input.line - 1, input.character - 1); 168: const displayPath = verbose ? input.filePath : getDisplayPath(input.filePath); 169: if (symbol) { 170: parts.push(`operation: "${input.operation}"`); 171: parts.push(`symbol: "${symbol}"`); 172: parts.push(`in: "${displayPath}"`); 173: } else { 174: parts.push(`operation: "${input.operation}"`); 175: parts.push(`file: "${displayPath}"`); 176: parts.push(`position: ${input.line}:${input.character}`); 177: } 178: return parts.join(', '); 179: } 180: parts.push(`operation: "${input.operation}"`); 181: if (input.filePath) { 182: const displayPath = verbose ? input.filePath : getDisplayPath(input.filePath); 183: parts.push(`file: "${displayPath}"`); 184: } 185: return parts.join(', '); 186: } 187: export function renderToolUseErrorMessage(result: ToolResultBlockParam['content'], { 188: verbose 189: }: { 190: verbose: boolean; 191: }): React.ReactNode { 192: if (!verbose && typeof result === 'string' && extractTag(result, 'tool_use_error')) { 193: return <MessageResponse> 194: <Text color="error">LSP operation failed</Text> 195: </MessageResponse>; 196: } 197: return <FallbackToolUseErrorMessage result={result} verbose={verbose} />; 198: } 199: export function renderToolResultMessage(output: Output, _progressMessages: unknown[], { 200: verbose 201: }: { 202: verbose: boolean; 203: }): React.ReactNode { 204: if (output.resultCount !== undefined && output.fileCount !== undefined) { 205: return <LSPResultSummary operation={output.operation} resultCount={output.resultCount} fileCount={output.fileCount} content={output.result} verbose={verbose} />; 206: } 207: return <MessageResponse> 208: <Text>{output.result}</Text> 209: </MessageResponse>; 210: }

File: src/tools/McpAuthTool/McpAuthTool.ts

typescript 1: import reject from 'lodash-es/reject.js' 2: import { z } from 'zod/v4' 3: import { performMCPOAuthFlow } from '../../services/mcp/auth.js' 4: import { 5: clearMcpAuthCache, 6: reconnectMcpServerImpl, 7: } from '../../services/mcp/client.js' 8: import { 9: buildMcpToolName, 10: getMcpPrefix, 11: } from '../../services/mcp/mcpStringUtils.js' 12: import type { 13: McpHTTPServerConfig, 14: McpSSEServerConfig, 15: ScopedMcpServerConfig, 16: } from '../../services/mcp/types.js' 17: import type { Tool } from '../../Tool.js' 18: import { errorMessage } from '../../utils/errors.js' 19: import { lazySchema } from '../../utils/lazySchema.js' 20: import { logMCPDebug, logMCPError } from '../../utils/log.js' 21: import type { PermissionDecision } from '../../utils/permissions/PermissionResult.js' 22: const inputSchema = lazySchema(() => z.object({})) 23: type InputSchema = ReturnType<typeof inputSchema> 24: export type McpAuthOutput = { 25: status: 'auth_url' | 'unsupported' | 'error' 26: message: string 27: authUrl?: string 28: } 29: function getConfigUrl(config: ScopedMcpServerConfig): string | undefined { 30: if ('url' in config) return config.url 31: return undefined 32: } 33: export function createMcpAuthTool( 34: serverName: string, 35: config: ScopedMcpServerConfig, 36: ): Tool<InputSchema, McpAuthOutput> { 37: const url = getConfigUrl(config) 38: const transport = config.type ?? 'stdio' 39: const location = url ? `${transport} at ${url}` : transport 40: const description = 41: `The \`${serverName}\` MCP server (${location}) is installed but requires authentication. ` + 42: `Call this tool to start the OAuth flow — you'll receive an authorization URL to share with the user. ` + 43: `Once the user completes authorization in their browser, the server's real tools will become available automatically.` 44: return { 45: name: buildMcpToolName(serverName, 'authenticate'), 46: isMcp: true, 47: mcpInfo: { serverName, toolName: 'authenticate' }, 48: isEnabled: () => true, 49: isConcurrencySafe: () => false, 50: isReadOnly: () => false, 51: toAutoClassifierInput: () => serverName, 52: userFacingName: () => `${serverName} - authenticate (MCP)`, 53: maxResultSizeChars: 10_000, 54: renderToolUseMessage: () => `Authenticate ${serverName} MCP server`, 55: async description() { 56: return description 57: }, 58: async prompt() { 59: return description 60: }, 61: get inputSchema(): InputSchema { 62: return inputSchema() 63: }, 64: async checkPermissions(input): Promise<PermissionDecision> { 65: return { behavior: 'allow', updatedInput: input } 66: }, 67: async call(_input, context) { 68: if (config.type === 'claudeai-proxy') { 69: return { 70: data: { 71: status: 'unsupported' as const, 72: message: `This is a claude.ai MCP connector. Ask the user to run /mcp and select "${serverName}" to authenticate.`, 73: }, 74: } 75: } 76: if (config.type !== 'sse' && config.type !== 'http') { 77: return { 78: data: { 79: status: 'unsupported' as const, 80: message: `Server "${serverName}" uses ${transport} transport which does not support OAuth from this tool. Ask the user to run /mcp and authenticate manually.`, 81: }, 82: } 83: } 84: const sseOrHttpConfig = config as ( 85: | McpSSEServerConfig 86: | McpHTTPServerConfig 87: ) & { scope: ScopedMcpServerConfig['scope'] } 88: let resolveAuthUrl: ((url: string) => void) | undefined 89: const authUrlPromise = new Promise<string>(resolve => { 90: resolveAuthUrl = resolve 91: }) 92: const controller = new AbortController() 93: const { setAppState } = context 94: const oauthPromise = performMCPOAuthFlow( 95: serverName, 96: sseOrHttpConfig, 97: u => resolveAuthUrl?.(u), 98: controller.signal, 99: { skipBrowserOpen: true }, 100: ) 101: void oauthPromise 102: .then(async () => { 103: clearMcpAuthCache() 104: const result = await reconnectMcpServerImpl(serverName, config) 105: const prefix = getMcpPrefix(serverName) 106: setAppState(prev => ({ 107: ...prev, 108: mcp: { 109: ...prev.mcp, 110: clients: prev.mcp.clients.map(c => 111: c.name === serverName ? result.client : c, 112: ), 113: tools: [ 114: ...reject(prev.mcp.tools, t => t.name?.startsWith(prefix)), 115: ...result.tools, 116: ], 117: commands: [ 118: ...reject(prev.mcp.commands, c => c.name?.startsWith(prefix)), 119: ...result.commands, 120: ], 121: resources: result.resources 122: ? { ...prev.mcp.resources, [serverName]: result.resources } 123: : prev.mcp.resources, 124: }, 125: })) 126: logMCPDebug( 127: serverName, 128: `OAuth complete, reconnected with ${result.tools.length} tool(s)`, 129: ) 130: }) 131: .catch(err => { 132: logMCPError( 133: serverName, 134: `OAuth flow failed after tool-triggered start: ${errorMessage(err)}`, 135: ) 136: }) 137: try { 138: const authUrl = await Promise.race([ 139: authUrlPromise, 140: oauthPromise.then(() => null as string | null), 141: ]) 142: if (authUrl) { 143: return { 144: data: { 145: status: 'auth_url' as const, 146: authUrl, 147: message: `Ask the user to open this URL in their browser to authorize the ${serverName} MCP server:\n\n${authUrl}\n\nOnce they complete the flow, the server's tools will become available automatically.`, 148: }, 149: } 150: } 151: return { 152: data: { 153: status: 'auth_url' as const, 154: message: `Authentication completed silently for ${serverName}. The server's tools should now be available.`, 155: }, 156: } 157: } catch (err) { 158: return { 159: data: { 160: status: 'error' as const, 161: message: `Failed to start OAuth flow for ${serverName}: ${errorMessage(err)}. Ask the user to run /mcp and authenticate manually.`, 162: }, 163: } 164: } 165: }, 166: mapToolResultToToolResultBlockParam(data, toolUseID) { 167: return { 168: tool_use_id: toolUseID, 169: type: 'tool_result', 170: content: data.message, 171: } 172: }, 173: } satisfies Tool<InputSchema, McpAuthOutput> 174: }

File: src/tools/MCPTool/classifyForCollapse.ts

typescript 1: const SEARCH_TOOLS = new Set([ 2: 'slack_search_public', 3: 'slack_search_public_and_private', 4: 'slack_search_channels', 5: 'slack_search_users', 6: 'search_code', 7: 'search_repositories', 8: 'search_issues', 9: 'search_pull_requests', 10: 'search_orgs', 11: 'search_users', 12: 'search_documentation', 13: 'search_logs', 14: 'search_spans', 15: 'search_rum_events', 16: 'search_audit_logs', 17: 'search_monitors', 18: 'search_monitor_groups', 19: 'find_slow_spans', 20: 'find_monitors_matching_pattern', 21: 'search_docs', 22: 'search_events', 23: 'search_issue_events', 24: 'find_organizations', 25: 'find_teams', 26: 'find_projects', 27: 'find_releases', 28: 'find_dsns', 29: 'search', 30: 'gmail_search_messages', 31: 'google_drive_search', 32: 'gcal_find_my_free_time', 33: 'gcal_find_meeting_times', 34: 'gcal_find_user_emails', 35: 'search_jira_issues_using_jql', 36: 'search_confluence_using_cql', 37: 'lookup_jira_account_id', 38: 'confluence_search', 39: 'jira_search', 40: 'jira_search_fields', 41: 'asana_search_tasks', 42: 'asana_typeahead_search', 43: 'search_files', 44: 'search_nodes', 45: 'brave_web_search', 46: 'brave_local_search', 47: 'search_dashboards', 48: 'search_folders', 49: 'search_docs', 50: 'search_stripe_resources', 51: 'search_stripe_documentation', 52: 'search_articles', 53: 'find_related_articles', 54: 'lookup_article_by_citation', 55: 'search_papers', 56: 'search_pubmed', 57: 'search_pubmed_key_words', 58: 'search_pubmed_advanced', 59: 'pubmed_search', 60: 'pubmed_mesh_lookup', 61: 'firecrawl_search', 62: 'web_search_exa', 63: 'web_search_advanced_exa', 64: 'people_search_exa', 65: 'linkedin_search_exa', 66: 'deep_search_exa', 67: 'perplexity_search', 68: 'perplexity_search_web', 69: 'tavily_search', 70: 'obsidian_simple_search', 71: 'obsidian_complex_search', 72: 'find', 73: 'search_knowledge', 74: 'search_memories', 75: 'find_memories_by_name', 76: 'search_records', 77: 'find_tasks', 78: 'find_tasks_by_date', 79: 'find_completed_tasks', 80: 'find_projects', 81: 'find_sections', 82: 'find_comments', 83: 'find_project_collaborators', 84: 'find_activity', 85: 'find_labels', 86: 'find_filters', 87: 'search_documentation', 88: 'search_catalog', 89: 'search_modules', 90: 'search_providers', 91: 'search_policies', 92: ]) 93: const READ_TOOLS = new Set([ 94: 'slack_read_channel', 95: 'slack_read_thread', 96: 'slack_read_canvas', 97: 'slack_read_user_profile', 98: 'slack_list_channels', 99: 'slack_get_channel_history', 100: 'slack_get_thread_replies', 101: 'slack_get_users', 102: 'slack_get_user_profile', 103: 'get_me', 104: 'get_team_members', 105: 'get_teams', 106: 'get_commit', 107: 'get_file_contents', 108: 'get_repository_tree', 109: 'list_branches', 110: 'list_commits', 111: 'list_releases', 112: 'list_tags', 113: 'get_latest_release', 114: 'get_release_by_tag', 115: 'get_tag', 116: 'list_issues', 117: 'issue_read', 118: 'list_issue_types', 119: 'get_label', 120: 'list_label', 121: 'pull_request_read', 122: 'get_gist', 123: 'list_gists', 124: 'list_notifications', 125: 'get_notification_details', 126: 'projects_list', 127: 'projects_get', 128: 'actions_get', 129: 'actions_list', 130: 'get_job_logs', 131: 'get_code_scanning_alert', 132: 'list_code_scanning_alerts', 133: 'get_dependabot_alert', 134: 'list_dependabot_alerts', 135: 'get_secret_scanning_alert', 136: 'list_secret_scanning_alerts', 137: 'get_global_security_advisory', 138: 'list_global_security_advisories', 139: 'list_org_repository_security_advisories', 140: 'list_repository_security_advisories', 141: 'get_discussion', 142: 'get_discussion_comments', 143: 'list_discussion_categories', 144: 'list_discussions', 145: 'list_starred_repositories', 146: 'get_issue', 147: 'get_pull_request', 148: 'list_pull_requests', 149: 'get_pull_request_files', 150: 'get_pull_request_status', 151: 'get_pull_request_comments', 152: 'get_pull_request_reviews', 153: 'list_comments', 154: 'list_cycles', 155: 'get_document', 156: 'list_documents', 157: 'list_issue_statuses', 158: 'get_issue_status', 159: 'list_my_issues', 160: 'list_issue_labels', 161: 'list_projects', 162: 'get_project', 163: 'list_project_labels', 164: 'list_teams', 165: 'get_team', 166: 'list_users', 167: 'get_user', 168: 'aggregate_logs', 169: 'list_spans', 170: 'aggregate_spans', 171: 'analyze_trace', 172: 'trace_critical_path', 173: 'query_metrics', 174: 'aggregate_rum_events', 175: 'list_rum_metrics', 176: 'get_rum_metric', 177: 'list_monitors', 178: 'get_monitor', 179: 'check_can_delete_monitor', 180: 'validate_monitor', 181: 'validate_existing_monitor', 182: 'list_dashboards', 183: 'get_dashboard', 184: 'query_dashboard_widget', 185: 'list_notebooks', 186: 'get_notebook', 187: 'query_notebook_cell', 188: 'get_profiling_metrics', 189: 'compare_profiling_metrics', 190: 'whoami', 191: 'get_issue_details', 192: 'get_issue_tag_values', 193: 'get_trace_details', 194: 'get_event_attachment', 195: 'get_doc', 196: 'get_sentry_resource', 197: 'list_events', 198: 'list_issue_events', 199: 'get_sentry_issue', 200: 'fetch', 201: 'get_comments', 202: 'get_users', 203: 'get_self', 204: 'gmail_get_profile', 205: 'gmail_read_message', 206: 'gmail_read_thread', 207: 'gmail_list_drafts', 208: 'gmail_list_labels', 209: 'google_drive_fetch', 210: 'google_drive_export', 211: 'gcal_list_calendars', 212: 'gcal_list_events', 213: 'gcal_get_event', 214: 'atlassian_user_info', 215: 'get_accessible_atlassian_resources', 216: 'get_visible_jira_projects', 217: 'get_jira_project_issue_types_metadata', 218: 'get_jira_issue', 219: 'get_transitions_for_jira_issue', 220: 'get_jira_issue_remote_issue_links', 221: 'get_confluence_spaces', 222: 'get_confluence_page', 223: 'get_pages_in_confluence_space', 224: 'get_confluence_page_ancestors', 225: 'get_confluence_page_descendants', 226: 'get_confluence_page_footer_comments', 227: 'get_confluence_page_inline_comments', 228: 'confluence_get_page', 229: 'confluence_get_page_children', 230: 'confluence_get_comments', 231: 'confluence_get_labels', 232: 'jira_get_issue', 233: 'jira_get_transitions', 234: 'jira_get_worklog', 235: 'jira_get_agile_boards', 236: 'jira_get_board_issues', 237: 'jira_get_sprints_from_board', 238: 'jira_get_sprint_issues', 239: 'jira_get_link_types', 240: 'jira_download_attachments', 241: 'jira_batch_get_changelogs', 242: 'jira_get_user_profile', 243: 'jira_get_project_issues', 244: 'jira_get_project_versions', 245: 'asana_get_attachment', 246: 'asana_get_attachments_for_object', 247: 'asana_get_goal', 248: 'asana_get_goals', 249: 'asana_get_parent_goals_for_goal', 250: 'asana_get_portfolio', 251: 'asana_get_portfolios', 252: 'asana_get_items_for_portfolio', 253: 'asana_get_project', 254: 'asana_get_projects', 255: 'asana_get_project_sections', 256: 'asana_get_project_status', 257: 'asana_get_project_statuses', 258: 'asana_get_project_task_counts', 259: 'asana_get_projects_for_team', 260: 'asana_get_projects_for_workspace', 261: 'asana_get_task', 262: 'asana_get_tasks', 263: 'asana_get_stories_for_task', 264: 'asana_get_teams_for_workspace', 265: 'asana_get_teams_for_user', 266: 'asana_get_team_users', 267: 'asana_get_time_period', 268: 'asana_get_time_periods', 269: 'asana_get_user', 270: 'asana_get_workspace_users', 271: 'asana_list_workspaces', 272: 'read_file', 273: 'read_text_file', 274: 'read_media_file', 275: 'read_multiple_files', 276: 'list_directory', 277: 'list_directory_with_sizes', 278: 'directory_tree', 279: 'get_file_info', 280: 'list_allowed_directories', 281: 'read_graph', 282: 'open_nodes', 283: 'query', 284: 'read_query', 285: 'list_tables', 286: 'describe_table', 287: 'git_status', 288: 'git_diff', 289: 'git_diff_unstaged', 290: 'git_diff_staged', 291: 'git_log', 292: 'git_show', 293: 'git_branch', 294: 'list_teams', 295: 'list_users_by_org', 296: 'get_dashboard_by_uid', 297: 'get_dashboard_summary', 298: 'get_dashboard_property', 299: 'get_dashboard_panel_queries', 300: 'run_panel_query', 301: 'list_datasources', 302: 'get_datasource', 303: 'get_query_examples', 304: 'query_prometheus', 305: 'query_prometheus_histogram', 306: 'list_prometheus_metric_metadata', 307: 'list_prometheus_metric_names', 308: 'list_prometheus_label_names', 309: 'list_prometheus_label_values', 310: 'query_loki_logs', 311: 'query_loki_stats', 312: 'query_loki_patterns', 313: 'list_loki_label_names', 314: 'list_loki_label_values', 315: 'list_incidents', 316: 'get_incident', 317: 'list_sift_investigations', 318: 'get_sift_investigation', 319: 'get_sift_analysis', 320: 'list_oncall_schedules', 321: 'get_oncall_shift', 322: 'get_current_oncall_users', 323: 'list_oncall_teams', 324: 'list_oncall_users', 325: 'list_alert_groups', 326: 'get_alert_group', 327: 'get_annotations', 328: 'get_annotation_tags', 329: 'get_panel_image', 330: 'list_incidents', 331: 'get_incident', 332: 'get_outlier_incident', 333: 'get_past_incidents', 334: 'get_related_incidents', 335: 'list_incident_notes', 336: 'list_incident_workflows', 337: 'get_incident_workflow', 338: 'list_services', 339: 'get_service', 340: 'list_team_members', 341: 'get_user_data', 342: 'list_schedules', 343: 'get_schedule', 344: 'list_schedule_users', 345: 'list_oncalls', 346: 'list_log_entries', 347: 'get_log_entry', 348: 'list_escalation_policies', 349: 'get_escalation_policy', 350: 'list_event_orchestrations', 351: 'get_event_orchestration', 352: 'list_status_pages', 353: 'get_status_page_post', 354: 'list_alerts_from_incident', 355: 'get_alert_from_incident', 356: 'list_change_events', 357: 'get_change_event', 358: 'list_organizations', 359: 'get_organization', 360: 'get_cost', 361: 'list_extensions', 362: 'list_migrations', 363: 'get_logs', 364: 'get_advisors', 365: 'get_project_url', 366: 'get_publishable_keys', 367: 'generate_typescript_types', 368: 'list_edge_functions', 369: 'get_edge_function', 370: 'list_storage_buckets', 371: 'get_storage_config', 372: 'get_stripe_account_info', 373: 'retrieve_balance', 374: 'list_customers', 375: 'list_products', 376: 'list_prices', 377: 'list_invoices', 378: 'list_payment_intents', 379: 'list_subscriptions', 380: 'list_coupons', 381: 'list_disputes', 382: 'fetch_stripe_resources', 383: 'get_article_metadata', 384: 'get_full_text_article', 385: 'convert_article_ids', 386: 'get_copyright_status', 387: 'download_paper', 388: 'list_papers', 389: 'read_paper', 390: 'get_paper_fulltext', 391: 'get_pubmed_article_metadata', 392: 'download_pubmed_pdf', 393: 'pubmed_fetch', 394: 'pubmed_pmc_fetch', 395: 'pubmed_spell', 396: 'pubmed_cite', 397: 'pubmed_related', 398: 'bigquery_query', 399: 'bigquery_schema', 400: 'list_dataset_ids', 401: 'list_table_ids', 402: 'get_dataset_info', 403: 'get_table_info', 404: 'firecrawl_scrape', 405: 'firecrawl_map', 406: 'firecrawl_crawl', 407: 'firecrawl_check_crawl_status', 408: 'firecrawl_extract', 409: 'get_code_context_exa', 410: 'company_research_exa', 411: 'crawling_exa', 412: 'deep_researcher_check', 413: 'perplexity_ask', 414: 'perplexity_research', 415: 'perplexity_reason', 416: 'tavily_extract', 417: 'tavily_crawl', 418: 'tavily_map', 419: 'tavily_research', 420: 'obsidian_list_files_in_vault', 421: 'obsidian_list_files_in_dir', 422: 'obsidian_get_file_contents', 423: 'obsidian_batch_get_file_contents', 424: 'obsidian_get_periodic_note', 425: 'obsidian_get_recent_periodic_notes', 426: 'obsidian_get_recent_changes', 427: 'get_figma_data', 428: 'download_figma_images', 429: 'browser_console_messages', 430: 'browser_network_requests', 431: 'browser_take_screenshot', 432: 'browser_snapshot', 433: 'browser_get_config', 434: 'browser_route_list', 435: 'browser_cookie_list', 436: 'browser_cookie_get', 437: 'browser_localstorage_list', 438: 'browser_localstorage_get', 439: 'browser_sessionstorage_list', 440: 'browser_sessionstorage_get', 441: 'browser_storage_state', 442: 'puppeteer_screenshot', 443: 'list_databases', 444: 'list_collections', 445: 'collection_indexes', 446: 'collection_schema', 447: 'collection_storage_size', 448: 'db_stats', 449: 'explain', 450: 'mongodb_logs', 451: 'aggregate', 452: 'count', 453: 'export', 454: 'get_neo4j_schema', 455: 'read_neo4j_cypher', 456: 'list_instances', 457: 'get_instance_details', 458: 'get_instance_by_name', 459: 'list_indices', 460: 'get_mappings', 461: 'esql', 462: 'get_shards', 463: 'list_records', 464: 'list_bases', 465: 'get_record', 466: 'get_productivity_stats', 467: 'get_overview', 468: 'fetch_object', 469: 'user_info', 470: 'list_workspaces', 471: 'view_attachment', 472: 'get_available_services', 473: 'read_documentation', 474: 'read_sections', 475: 'recommend', 476: 'analyze_log_group', 477: 'analyze_metric', 478: 'describe_log_groups', 479: 'get_active_alarms', 480: 'get_alarm_history', 481: 'get_metric_data', 482: 'get_metric_metadata', 483: 'kubectl_get', 484: 'kubectl_describe', 485: 'kubectl_logs', 486: 'kubectl_context', 487: 'explain_resource', 488: 'list_api_resources', 489: 'namespaces_list', 490: 'nodes_log', 491: 'nodes_top', 492: 'pods_get', 493: 'pods_list', 494: 'pods_list_in_namespace', 495: 'pods_log', 496: 'pods_top', 497: 'resources_get', 498: 'resources_list', 499: ]) 500: function normalize(name: string): string { 501: return name 502: .replace(/([a-z])([A-Z])/g, '$1_$2') 503: .replace(/-/g, '_') 504: .toLowerCase() 505: } 506: export function classifyMcpToolForCollapse( 507: _serverName: string, 508: toolName: string, 509: ): { isSearch: boolean; isRead: boolean } { 510: const normalized = normalize(toolName) 511: return { 512: isSearch: SEARCH_TOOLS.has(normalized), 513: isRead: READ_TOOLS.has(normalized), 514: } 515: }

File: src/tools/MCPTool/MCPTool.ts

typescript 1: import { z } from 'zod/v4' 2: import { buildTool, type ToolDef } from '../../Tool.js' 3: import { lazySchema } from '../../utils/lazySchema.js' 4: import type { PermissionResult } from '../../utils/permissions/PermissionResult.js' 5: import { isOutputLineTruncated } from '../../utils/terminal.js' 6: import { DESCRIPTION, PROMPT } from './prompt.js' 7: import { 8: renderToolResultMessage, 9: renderToolUseMessage, 10: renderToolUseProgressMessage, 11: } from './UI.js' 12: export const inputSchema = lazySchema(() => z.object({}).passthrough()) 13: type InputSchema = ReturnType<typeof inputSchema> 14: export const outputSchema = lazySchema(() => 15: z.string().describe('MCP tool execution result'), 16: ) 17: type OutputSchema = ReturnType<typeof outputSchema> 18: export type Output = z.infer<OutputSchema> 19: export type { MCPProgress } from '../../types/tools.js' 20: export const MCPTool = buildTool({ 21: isMcp: true, 22: isOpenWorld() { 23: return false 24: }, 25: name: 'mcp', 26: maxResultSizeChars: 100_000, 27: async description() { 28: return DESCRIPTION 29: }, 30: async prompt() { 31: return PROMPT 32: }, 33: get inputSchema(): InputSchema { 34: return inputSchema() 35: }, 36: get outputSchema(): OutputSchema { 37: return outputSchema() 38: }, 39: async call() { 40: return { 41: data: '', 42: } 43: }, 44: async checkPermissions(): Promise<PermissionResult> { 45: return { 46: behavior: 'passthrough', 47: message: 'MCPTool requires permission.', 48: } 49: }, 50: renderToolUseMessage, 51: userFacingName: () => 'mcp', 52: renderToolUseProgressMessage, 53: renderToolResultMessage, 54: isResultTruncated(output: Output): boolean { 55: return isOutputLineTruncated(output) 56: }, 57: mapToolResultToToolResultBlockParam(content, toolUseID) { 58: return { 59: tool_use_id: toolUseID, 60: type: 'tool_result', 61: content, 62: } 63: }, 64: } satisfies ToolDef<InputSchema, Output>)

File: src/tools/MCPTool/prompt.ts

typescript 1: export const PROMPT = '' 2: export const DESCRIPTION = ''

File: src/tools/MCPTool/UI.tsx

typescript 1: import { c as _c } from "react/compiler-runtime"; 2: import { feature } from 'bun:bundle'; 3: import figures from 'figures'; 4: import * as React from 'react'; 5: import type { z } from 'zod/v4'; 6: import { ProgressBar } from '../../components/design-system/ProgressBar.js'; 7: import { MessageResponse } from '../../components/MessageResponse.js'; 8: import { linkifyUrlsInText, OutputLine } from '../../components/shell/OutputLine.js'; 9: import { stringWidth } from '../../ink/stringWidth.js'; 10: import { Ansi, Box, Text } from '../../ink.js'; 11: import type { ToolProgressData } from '../../Tool.js'; 12: import type { ProgressMessage } from '../../types/message.js'; 13: import type { MCPProgress } from '../../types/tools.js'; 14: import { formatNumber } from '../../utils/format.js'; 15: import { createHyperlink } from '../../utils/hyperlink.js'; 16: import { getContentSizeEstimate, type MCPToolResult } from '../../utils/mcpValidation.js'; 17: import { jsonParse, jsonStringify } from '../../utils/slowOperations.js'; 18: import type { inputSchema } from './MCPTool.js'; 19: const MCP_OUTPUT_WARNING_THRESHOLD_TOKENS = 10_000; 20: const MAX_INPUT_VALUE_CHARS = 80; 21: const MAX_FLAT_JSON_KEYS = 12; 22: const MAX_FLAT_JSON_CHARS = 5_000; 23: const MAX_JSON_PARSE_CHARS = 200_000; 24: const UNWRAP_MIN_STRING_LEN = 200; 25: export function renderToolUseMessage(input: z.infer<ReturnType<typeof inputSchema>>, { 26: verbose 27: }: { 28: verbose: boolean; 29: }): React.ReactNode { 30: if (Object.keys(input).length === 0) { 31: return ''; 32: } 33: return Object.entries(input).map(([key, value]) => { 34: let rendered = jsonStringify(value); 35: if (feature('MCP_RICH_OUTPUT') && !verbose && rendered.length > MAX_INPUT_VALUE_CHARS) { 36: rendered = rendered.slice(0, MAX_INPUT_VALUE_CHARS).trimEnd() + '…'; 37: } 38: return `${key}: ${rendered}`; 39: }).join(', '); 40: } 41: export function renderToolUseProgressMessage(progressMessagesForMessage: ProgressMessage<MCPProgress>[]): React.ReactNode { 42: const lastProgress = progressMessagesForMessage.at(-1); 43: if (!lastProgress?.data) { 44: return <MessageResponse height={1}> 45: <Text dimColor>Running…</Text> 46: </MessageResponse>; 47: } 48: const { 49: progress, 50: total, 51: progressMessage 52: } = lastProgress.data; 53: if (progress === undefined) { 54: return <MessageResponse height={1}> 55: <Text dimColor>Running…</Text> 56: </MessageResponse>; 57: } 58: if (total !== undefined && total > 0) { 59: const ratio = Math.min(1, Math.max(0, progress / total)); 60: const percentage = Math.round(ratio * 100); 61: return <MessageResponse> 62: <Box flexDirection="column"> 63: {progressMessage && <Text dimColor>{progressMessage}</Text>} 64: <Box flexDirection="row" gap={1}> 65: <ProgressBar ratio={ratio} width={20} /> 66: <Text dimColor>{percentage}%</Text> 67: </Box> 68: </Box> 69: </MessageResponse>; 70: } 71: return <MessageResponse height={1}> 72: <Text dimColor>{progressMessage ?? `Processing… ${progress}`}</Text> 73: </MessageResponse>; 74: } 75: export function renderToolResultMessage(output: string | MCPToolResult, _progressMessagesForMessage: ProgressMessage<ToolProgressData>[], { 76: verbose, 77: input 78: }: { 79: verbose: boolean; 80: input?: unknown; 81: }): React.ReactNode { 82: const mcpOutput = output as MCPToolResult; 83: if (!verbose) { 84: const slackSend = trySlackSendCompact(mcpOutput, input); 85: if (slackSend !== null) { 86: return <MessageResponse height={1}> 87: <Text> 88: Sent a message to{' '} 89: <Ansi>{createHyperlink(slackSend.url, slackSend.channel)}</Ansi> 90: </Text> 91: </MessageResponse>; 92: } 93: } 94: const estimatedTokens = getContentSizeEstimate(mcpOutput); 95: const showWarning = estimatedTokens > MCP_OUTPUT_WARNING_THRESHOLD_TOKENS; 96: const warningMessage = showWarning ? `${figures.warning} Large MCP response (~${formatNumber(estimatedTokens)} tokens), this can fill up context quickly` : null; 97: let contentElement: React.ReactNode; 98: if (Array.isArray(mcpOutput)) { 99: const contentBlocks = mcpOutput.map((item, i) => { 100: if (item.type === 'image') { 101: return <Box key={i} justifyContent="space-between" overflowX="hidden" width="100%"> 102: <MessageResponse height={1}> 103: <Text>[Image]</Text> 104: </MessageResponse> 105: </Box>; 106: } 107: const textContent = item.type === 'text' && 'text' in item && item.text !== null && item.text !== undefined ? String(item.text) : ''; 108: return feature('MCP_RICH_OUTPUT') ? <MCPTextOutput key={i} content={textContent} verbose={verbose} /> : <OutputLine key={i} content={textContent} verbose={verbose} />; 109: }); 110: contentElement = <Box flexDirection="column" width="100%"> 111: {contentBlocks} 112: </Box>; 113: } else if (!mcpOutput) { 114: contentElement = <Box justifyContent="space-between" overflowX="hidden" width="100%"> 115: <MessageResponse height={1}> 116: <Text dimColor>(No content)</Text> 117: </MessageResponse> 118: </Box>; 119: } else { 120: contentElement = feature('MCP_RICH_OUTPUT') ? <MCPTextOutput content={mcpOutput} verbose={verbose} /> : <OutputLine content={mcpOutput} verbose={verbose} />; 121: } 122: if (warningMessage) { 123: return <Box flexDirection="column"> 124: <MessageResponse height={1}> 125: <Text color="warning">{warningMessage}</Text> 126: </MessageResponse> 127: {contentElement} 128: </Box>; 129: } 130: return contentElement; 131: } 132: function MCPTextOutput(t0) { 133: const $ = _c(18); 134: const { 135: content, 136: verbose 137: } = t0; 138: let t1; 139: if ($[0] !== content || $[1] !== verbose) { 140: t1 = Symbol.for("react.early_return_sentinel"); 141: bb0: { 142: const unwrapped = tryUnwrapTextPayload(content); 143: if (unwrapped !== null) { 144: const t2 = unwrapped.extras.length > 0 && <Text dimColor={true}>{unwrapped.extras.map(_temp).join(" \xB7 ")}</Text>; 145: let t3; 146: if ($[3] !== unwrapped || $[4] !== verbose) { 147: t3 = <OutputLine content={unwrapped.body} verbose={verbose} linkifyUrls={true} />; 148: $[3] = unwrapped; 149: $[4] = verbose; 150: $[5] = t3; 151: } else { 152: t3 = $[5]; 153: } 154: let t4; 155: if ($[6] !== t2 || $[7] !== t3) { 156: t4 = <MessageResponse><Box flexDirection="column">{t2}{t3}</Box></MessageResponse>; 157: $[6] = t2; 158: $[7] = t3; 159: $[8] = t4; 160: } else { 161: t4 = $[8]; 162: } 163: t1 = t4; 164: break bb0; 165: } 166: } 167: $[0] = content; 168: $[1] = verbose; 169: $[2] = t1; 170: } else { 171: t1 = $[2]; 172: } 173: if (t1 !== Symbol.for("react.early_return_sentinel")) { 174: return t1; 175: } 176: let t2; 177: if ($[9] !== content) { 178: t2 = Symbol.for("react.early_return_sentinel"); 179: bb1: { 180: const flat = tryFlattenJson(content); 181: if (flat !== null) { 182: const maxKeyWidth = Math.max(...flat.map(_temp2)); 183: let t3; 184: if ($[11] !== maxKeyWidth) { 185: t3 = (t4, i) => { 186: const [key, value] = t4; 187: return <Text key={i}><Text dimColor={true}>{key.padEnd(maxKeyWidth)}: </Text><Ansi>{linkifyUrlsInText(value)}</Ansi></Text>; 188: }; 189: $[11] = maxKeyWidth; 190: $[12] = t3; 191: } else { 192: t3 = $[12]; 193: } 194: const t4 = <Box flexDirection="column">{flat.map(t3)}</Box>; 195: let t5; 196: if ($[13] !== t4) { 197: t5 = <MessageResponse>{t4}</MessageResponse>; 198: $[13] = t4; 199: $[14] = t5; 200: } else { 201: t5 = $[14]; 202: } 203: t2 = t5; 204: break bb1; 205: } 206: } 207: $[9] = content; 208: $[10] = t2; 209: } else { 210: t2 = $[10]; 211: } 212: if (t2 !== Symbol.for("react.early_return_sentinel")) { 213: return t2; 214: } 215: let t3; 216: if ($[15] !== content || $[16] !== verbose) { 217: t3 = <OutputLine content={content} verbose={verbose} linkifyUrls={true} />; 218: $[15] = content; 219: $[16] = verbose; 220: $[17] = t3; 221: } else { 222: t3 = $[17]; 223: } 224: return t3; 225: } 226: function _temp2(t0) { 227: const [k_0] = t0; 228: return stringWidth(k_0); 229: } 230: function _temp(t0) { 231: const [k, v] = t0; 232: return `${k}: ${v}`; 233: } 234: function parseJsonEntries(content: string, { 235: maxChars, 236: maxKeys 237: }: { 238: maxChars: number; 239: maxKeys: number; 240: }): [string, unknown][] | null { 241: const trimmed = content.trim(); 242: if (trimmed.length === 0 || trimmed.length > maxChars || trimmed[0] !== '{') { 243: return null; 244: } 245: let parsed: unknown; 246: try { 247: parsed = jsonParse(trimmed); 248: } catch { 249: return null; 250: } 251: if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { 252: return null; 253: } 254: const entries = Object.entries(parsed); 255: if (entries.length === 0 || entries.length > maxKeys) { 256: return null; 257: } 258: return entries; 259: } 260: export function tryFlattenJson(content: string): [string, string][] | null { 261: const entries = parseJsonEntries(content, { 262: maxChars: MAX_FLAT_JSON_CHARS, 263: maxKeys: MAX_FLAT_JSON_KEYS 264: }); 265: if (entries === null) return null; 266: const result: [string, string][] = []; 267: for (const [key, value] of entries) { 268: if (typeof value === 'string') { 269: result.push([key, value]); 270: } else if (value === null || typeof value === 'number' || typeof value === 'boolean') { 271: result.push([key, String(value)]); 272: } else if (typeof value === 'object') { 273: const compact = jsonStringify(value); 274: if (compact.length > 120) return null; 275: result.push([key, compact]); 276: } else { 277: return null; 278: } 279: } 280: return result; 281: } 282: export function tryUnwrapTextPayload(content: string): { 283: body: string; 284: extras: [string, string][]; 285: } | null { 286: const entries = parseJsonEntries(content, { 287: maxChars: MAX_JSON_PARSE_CHARS, 288: maxKeys: 4 289: }); 290: if (entries === null) return null; 291: let body: string | null = null; 292: const extras: [string, string][] = []; 293: for (const [key, value] of entries) { 294: if (typeof value === 'string') { 295: const t = value.trimEnd(); 296: const isDominant = t.length > UNWRAP_MIN_STRING_LEN || t.includes('\n') && t.length > 50; 297: if (isDominant) { 298: if (body !== null) return null; 299: body = t; 300: continue; 301: } 302: if (t.length > 150) return null; 303: extras.push([key, t.replace(/\s+/g, ' ')]); 304: } else if (value === null || typeof value === 'number' || typeof value === 'boolean') { 305: extras.push([key, String(value)]); 306: } else { 307: return null; 308: } 309: } 310: if (body === null) return null; 311: return { 312: body, 313: extras 314: }; 315: } 316: const SLACK_ARCHIVES_RE = /^https:\/\/[a-z0-9-]+\.slack\.com\/archives\/([A-Z0-9]+)\/p\d+$/; 317: export function trySlackSendCompact(output: string | MCPToolResult, input: unknown): { 318: channel: string; 319: url: string; 320: } | null { 321: let text: unknown = output; 322: if (Array.isArray(output)) { 323: const block = output.find(b => b.type === 'text'); 324: text = block && 'text' in block ? block.text : undefined; 325: } 326: if (typeof text !== 'string' || !text.includes('"message_link"')) { 327: return null; 328: } 329: const entries = parseJsonEntries(text, { 330: maxChars: 2000, 331: maxKeys: 6 332: }); 333: const url = entries?.find(([k]) => k === 'message_link')?.[1]; 334: if (typeof url !== 'string') return null; 335: const m = SLACK_ARCHIVES_RE.exec(url); 336: if (!m) return null; 337: const inp = input as { 338: channel_id?: unknown; 339: channel?: unknown; 340: } | undefined; 341: const raw = inp?.channel_id ?? inp?.channel ?? m[1]; 342: const label = typeof raw === 'string' && raw ? raw : 'slack'; 343: return { 344: channel: label.startsWith('#') ? label : `#${label}`, 345: url 346: }; 347: }

File: src/tools/NotebookEditTool/constants.ts

typescript 1: export const NOTEBOOK_EDIT_TOOL_NAME = 'NotebookEdit'

File: src/tools/NotebookEditTool/NotebookEditTool.ts

typescript 1: import { feature } from 'bun:bundle' 2: import { extname, isAbsolute, resolve } from 'path' 3: import { 4: fileHistoryEnabled, 5: fileHistoryTrackEdit, 6: } from 'src/utils/fileHistory.js' 7: import { z } from 'zod/v4' 8: import { buildTool, type ToolDef, type ToolUseContext } from '../../Tool.js' 9: import type { NotebookCell, NotebookContent } from '../../types/notebook.js' 10: import { getCwd } from '../../utils/cwd.js' 11: import { isENOENT } from '../../utils/errors.js' 12: import { getFileModificationTime, writeTextContent } from '../../utils/file.js' 13: import { readFileSyncWithMetadata } from '../../utils/fileRead.js' 14: import { safeParseJSON } from '../../utils/json.js' 15: import { lazySchema } from '../../utils/lazySchema.js' 16: import { parseCellId } from '../../utils/notebook.js' 17: import { checkWritePermissionForTool } from '../../utils/permissions/filesystem.js' 18: import type { PermissionDecision } from '../../utils/permissions/PermissionResult.js' 19: import { jsonParse, jsonStringify } from '../../utils/slowOperations.js' 20: import { NOTEBOOK_EDIT_TOOL_NAME } from './constants.js' 21: import { DESCRIPTION, PROMPT } from './prompt.js' 22: import { 23: getToolUseSummary, 24: renderToolResultMessage, 25: renderToolUseErrorMessage, 26: renderToolUseMessage, 27: renderToolUseRejectedMessage, 28: } from './UI.js' 29: export const inputSchema = lazySchema(() => 30: z.strictObject({ 31: notebook_path: z 32: .string() 33: .describe( 34: 'The absolute path to the Jupyter notebook file to edit (must be absolute, not relative)', 35: ), 36: cell_id: z 37: .string() 38: .optional() 39: .describe( 40: 'The ID of the cell to edit. When inserting a new cell, the new cell will be inserted after the cell with this ID, or at the beginning if not specified.', 41: ), 42: new_source: z.string().describe('The new source for the cell'), 43: cell_type: z 44: .enum(['code', 'markdown']) 45: .optional() 46: .describe( 47: 'The type of the cell (code or markdown). If not specified, it defaults to the current cell type. If using edit_mode=insert, this is required.', 48: ), 49: edit_mode: z 50: .enum(['replace', 'insert', 'delete']) 51: .optional() 52: .describe( 53: 'The type of edit to make (replace, insert, delete). Defaults to replace.', 54: ), 55: }), 56: ) 57: type InputSchema = ReturnType<typeof inputSchema> 58: export const outputSchema = lazySchema(() => 59: z.object({ 60: new_source: z 61: .string() 62: .describe('The new source code that was written to the cell'), 63: cell_id: z 64: .string() 65: .optional() 66: .describe('The ID of the cell that was edited'), 67: cell_type: z.enum(['code', 'markdown']).describe('The type of the cell'), 68: language: z.string().describe('The programming language of the notebook'), 69: edit_mode: z.string().describe('The edit mode that was used'), 70: error: z 71: .string() 72: .optional() 73: .describe('Error message if the operation failed'), 74: notebook_path: z.string().describe('The path to the notebook file'), 75: original_file: z 76: .string() 77: .describe('The original notebook content before modification'), 78: updated_file: z 79: .string() 80: .describe('The updated notebook content after modification'), 81: }), 82: ) 83: type OutputSchema = ReturnType<typeof outputSchema> 84: export type Output = z.infer<OutputSchema> 85: export const NotebookEditTool = buildTool({ 86: name: NOTEBOOK_EDIT_TOOL_NAME, 87: searchHint: 'edit Jupyter notebook cells (.ipynb)', 88: maxResultSizeChars: 100_000, 89: shouldDefer: true, 90: async description() { 91: return DESCRIPTION 92: }, 93: async prompt() { 94: return PROMPT 95: }, 96: userFacingName() { 97: return 'Edit Notebook' 98: }, 99: getToolUseSummary, 100: getActivityDescription(input) { 101: const summary = getToolUseSummary(input) 102: return summary ? `Editing notebook ${summary}` : 'Editing notebook' 103: }, 104: get inputSchema(): InputSchema { 105: return inputSchema() 106: }, 107: get outputSchema(): OutputSchema { 108: return outputSchema() 109: }, 110: toAutoClassifierInput(input) { 111: if (feature('TRANSCRIPT_CLASSIFIER')) { 112: const mode = input.edit_mode ?? 'replace' 113: return `${input.notebook_path} ${mode}: ${input.new_source}` 114: } 115: return '' 116: }, 117: getPath(input): string { 118: return input.notebook_path 119: }, 120: async checkPermissions(input, context): Promise<PermissionDecision> { 121: const appState = context.getAppState() 122: return checkWritePermissionForTool( 123: NotebookEditTool, 124: input, 125: appState.toolPermissionContext, 126: ) 127: }, 128: mapToolResultToToolResultBlockParam( 129: { cell_id, edit_mode, new_source, error }, 130: toolUseID, 131: ) { 132: if (error) { 133: return { 134: tool_use_id: toolUseID, 135: type: 'tool_result', 136: content: error, 137: is_error: true, 138: } 139: } 140: switch (edit_mode) { 141: case 'replace': 142: return { 143: tool_use_id: toolUseID, 144: type: 'tool_result', 145: content: `Updated cell ${cell_id} with ${new_source}`, 146: } 147: case 'insert': 148: return { 149: tool_use_id: toolUseID, 150: type: 'tool_result', 151: content: `Inserted cell ${cell_id} with ${new_source}`, 152: } 153: case 'delete': 154: return { 155: tool_use_id: toolUseID, 156: type: 'tool_result', 157: content: `Deleted cell ${cell_id}`, 158: } 159: default: 160: return { 161: tool_use_id: toolUseID, 162: type: 'tool_result', 163: content: 'Unknown edit mode', 164: } 165: } 166: }, 167: renderToolUseMessage, 168: renderToolUseRejectedMessage, 169: renderToolUseErrorMessage, 170: renderToolResultMessage, 171: async validateInput( 172: { notebook_path, cell_type, cell_id, edit_mode = 'replace' }, 173: toolUseContext: ToolUseContext, 174: ) { 175: const fullPath = isAbsolute(notebook_path) 176: ? notebook_path 177: : resolve(getCwd(), notebook_path) 178: if (fullPath.startsWith('\\\\') || fullPath.startsWith(' 179: return { result: true } 180: } 181: if (extname(fullPath) !== '.ipynb') { 182: return { 183: result: false, 184: message: 185: 'File must be a Jupyter notebook (.ipynb file). For editing other file types, use the FileEdit tool.', 186: errorCode: 2, 187: } 188: } 189: if ( 190: edit_mode !== 'replace' && 191: edit_mode !== 'insert' && 192: edit_mode !== 'delete' 193: ) { 194: return { 195: result: false, 196: message: 'Edit mode must be replace, insert, or delete.', 197: errorCode: 4, 198: } 199: } 200: if (edit_mode === 'insert' && !cell_type) { 201: return { 202: result: false, 203: message: 'Cell type is required when using edit_mode=insert.', 204: errorCode: 5, 205: } 206: } 207: const readTimestamp = toolUseContext.readFileState.get(fullPath) 208: if (!readTimestamp) { 209: return { 210: result: false, 211: message: 212: 'File has not been read yet. Read it first before writing to it.', 213: errorCode: 9, 214: } 215: } 216: if (getFileModificationTime(fullPath) > readTimestamp.timestamp) { 217: return { 218: result: false, 219: message: 220: 'File has been modified since read, either by the user or by a linter. Read it again before attempting to write it.', 221: errorCode: 10, 222: } 223: } 224: let content: string 225: try { 226: content = readFileSyncWithMetadata(fullPath).content 227: } catch (e) { 228: if (isENOENT(e)) { 229: return { 230: result: false, 231: message: 'Notebook file does not exist.', 232: errorCode: 1, 233: } 234: } 235: throw e 236: } 237: const notebook = safeParseJSON(content) as NotebookContent | null 238: if (!notebook) { 239: return { 240: result: false, 241: message: 'Notebook is not valid JSON.', 242: errorCode: 6, 243: } 244: } 245: if (!cell_id) { 246: if (edit_mode !== 'insert') { 247: return { 248: result: false, 249: message: 'Cell ID must be specified when not inserting a new cell.', 250: errorCode: 7, 251: } 252: } 253: } else { 254: const cellIndex = notebook.cells.findIndex(cell => cell.id === cell_id) 255: if (cellIndex === -1) { 256: const parsedCellIndex = parseCellId(cell_id) 257: if (parsedCellIndex !== undefined) { 258: if (!notebook.cells[parsedCellIndex]) { 259: return { 260: result: false, 261: message: `Cell with index ${parsedCellIndex} does not exist in notebook.`, 262: errorCode: 7, 263: } 264: } 265: } else { 266: return { 267: result: false, 268: message: `Cell with ID "${cell_id}" not found in notebook.`, 269: errorCode: 8, 270: } 271: } 272: } 273: } 274: return { result: true } 275: }, 276: async call( 277: { 278: notebook_path, 279: new_source, 280: cell_id, 281: cell_type, 282: edit_mode: originalEditMode, 283: }, 284: { readFileState, updateFileHistoryState }, 285: _, 286: parentMessage, 287: ) { 288: const fullPath = isAbsolute(notebook_path) 289: ? notebook_path 290: : resolve(getCwd(), notebook_path) 291: if (fileHistoryEnabled()) { 292: await fileHistoryTrackEdit( 293: updateFileHistoryState, 294: fullPath, 295: parentMessage.uuid, 296: ) 297: } 298: try { 299: const { content, encoding, lineEndings } = 300: readFileSyncWithMetadata(fullPath) 301: let notebook: NotebookContent 302: try { 303: notebook = jsonParse(content) as NotebookContent 304: } catch { 305: return { 306: data: { 307: new_source, 308: cell_type: cell_type ?? 'code', 309: language: 'python', 310: edit_mode: 'replace', 311: error: 'Notebook is not valid JSON.', 312: cell_id, 313: notebook_path: fullPath, 314: original_file: '', 315: updated_file: '', 316: }, 317: } 318: } 319: let cellIndex 320: if (!cell_id) { 321: cellIndex = 0 // Default to inserting at the beginning if no cell_id is provided 322: } else { 323: // First try to find the cell by its actual ID 324: cellIndex = notebook.cells.findIndex(cell => cell.id === cell_id) 325: // If not found, try to parse as a numeric index (cell-N format) 326: if (cellIndex === -1) { 327: const parsedCellIndex = parseCellId(cell_id) 328: if (parsedCellIndex !== undefined) { 329: cellIndex = parsedCellIndex 330: } 331: } 332: if (originalEditMode === 'insert') { 333: cellIndex += 1 334: } 335: } 336: let edit_mode = originalEditMode 337: if (edit_mode === 'replace' && cellIndex === notebook.cells.length) { 338: edit_mode = 'insert' 339: if (!cell_type) { 340: cell_type = 'code' 341: } 342: } 343: const language = notebook.metadata.language_info?.name ?? 'python' 344: let new_cell_id = undefined 345: if ( 346: notebook.nbformat > 4 || 347: (notebook.nbformat === 4 && notebook.nbformat_minor >= 5) 348: ) { 349: if (edit_mode === 'insert') { 350: new_cell_id = Math.random().toString(36).substring(2, 15) 351: } else if (cell_id !== null) { 352: new_cell_id = cell_id 353: } 354: } 355: if (edit_mode === 'delete') { 356: notebook.cells.splice(cellIndex, 1) 357: } else if (edit_mode === 'insert') { 358: let new_cell: NotebookCell 359: if (cell_type === 'markdown') { 360: new_cell = { 361: cell_type: 'markdown', 362: id: new_cell_id, 363: source: new_source, 364: metadata: {}, 365: } 366: } else { 367: new_cell = { 368: cell_type: 'code', 369: id: new_cell_id, 370: source: new_source, 371: metadata: {}, 372: execution_count: null, 373: outputs: [], 374: } 375: } 376: notebook.cells.splice(cellIndex, 0, new_cell) 377: } else { 378: const targetCell = notebook.cells[cellIndex]! 379: targetCell.source = new_source 380: if (targetCell.cell_type === 'code') { 381: targetCell.execution_count = null 382: targetCell.outputs = [] 383: } 384: if (cell_type && cell_type !== targetCell.cell_type) { 385: targetCell.cell_type = cell_type 386: } 387: } 388: const IPYNB_INDENT = 1 389: const updatedContent = jsonStringify(notebook, null, IPYNB_INDENT) 390: writeTextContent(fullPath, updatedContent, encoding, lineEndings) 391: readFileState.set(fullPath, { 392: content: updatedContent, 393: timestamp: getFileModificationTime(fullPath), 394: offset: undefined, 395: limit: undefined, 396: }) 397: const data = { 398: new_source, 399: cell_type: cell_type ?? 'code', 400: language, 401: edit_mode: edit_mode ?? 'replace', 402: cell_id: new_cell_id || undefined, 403: error: '', 404: notebook_path: fullPath, 405: original_file: content, 406: updated_file: updatedContent, 407: } 408: return { 409: data, 410: } 411: } catch (error) { 412: if (error instanceof Error) { 413: const data = { 414: new_source, 415: cell_type: cell_type ?? 'code', 416: language: 'python', 417: edit_mode: 'replace', 418: error: error.message, 419: cell_id, 420: notebook_path: fullPath, 421: original_file: '', 422: updated_file: '', 423: } 424: return { 425: data, 426: } 427: } 428: const data = { 429: new_source, 430: cell_type: cell_type ?? 'code', 431: language: 'python', 432: edit_mode: 'replace', 433: error: 'Unknown error occurred while editing notebook', 434: cell_id, 435: notebook_path: fullPath, 436: original_file: '', 437: updated_file: '', 438: } 439: return { 440: data, 441: } 442: } 443: }, 444: } satisfies ToolDef<InputSchema, Output>)

File: src/tools/NotebookEditTool/prompt.ts

typescript 1: export const DESCRIPTION = 2: 'Replace the contents of a specific cell in a Jupyter notebook.' 3: export const PROMPT = `Completely replaces the contents of a specific cell in a Jupyter notebook (.ipynb file) with new source. Jupyter notebooks are interactive documents that combine code, text, and visualizations, commonly used for data analysis and scientific computing. The notebook_path parameter must be an absolute path, not a relative path. The cell_number is 0-indexed. Use edit_mode=insert to add a new cell at the index specified by cell_number. Use edit_mode=delete to delete the cell at the index specified by cell_number.`

File: src/tools/NotebookEditTool/UI.tsx

typescript 1: import type { ToolResultBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'; 2: import * as React from 'react'; 3: import type { Message, ProgressMessage } from 'src/types/message.js'; 4: import { extractTag } from 'src/utils/messages.js'; 5: import type { ThemeName } from 'src/utils/theme.js'; 6: import type { z } from 'zod/v4'; 7: import { FallbackToolUseErrorMessage } from '../../components/FallbackToolUseErrorMessage.js'; 8: import { FilePathLink } from '../../components/FilePathLink.js'; 9: import { HighlightedCode } from '../../components/HighlightedCode.js'; 10: import { MessageResponse } from '../../components/MessageResponse.js'; 11: import { NotebookEditToolUseRejectedMessage } from '../../components/NotebookEditToolUseRejectedMessage.js'; 12: import { Box, Text } from '../../ink.js'; 13: import type { Tools } from '../../Tool.js'; 14: import { getDisplayPath } from '../../utils/file.js'; 15: import type { inputSchema, Output } from './NotebookEditTool.js'; 16: export function getToolUseSummary(input: Partial<z.infer<ReturnType<typeof inputSchema>>> | undefined): string | null { 17: if (!input?.notebook_path) { 18: return null; 19: } 20: return getDisplayPath(input.notebook_path); 21: } 22: export function renderToolUseMessage({ 23: notebook_path, 24: cell_id, 25: new_source, 26: cell_type, 27: edit_mode 28: }: Partial<z.infer<ReturnType<typeof inputSchema>>>, { 29: verbose 30: }: { 31: verbose: boolean; 32: }): React.ReactNode { 33: if (!notebook_path || !new_source || !cell_type) { 34: return null; 35: } 36: const displayPath = verbose ? notebook_path : getDisplayPath(notebook_path); 37: if (verbose) { 38: return <> 39: <FilePathLink filePath={notebook_path}>{displayPath}</FilePathLink> 40: {`@${cell_id}, content: ${new_source.slice(0, 30)}…, cell_type: ${cell_type}, edit_mode: ${edit_mode ?? 'replace'}`} 41: </>; 42: } 43: return <> 44: <FilePathLink filePath={notebook_path}>{displayPath}</FilePathLink> 45: {`@${cell_id}`} 46: </>; 47: } 48: export function renderToolUseRejectedMessage(input: z.infer<ReturnType<typeof inputSchema>>, { 49: verbose 50: }: { 51: columns?: number; 52: messages?: Message[]; 53: progressMessagesForMessage?: ProgressMessage[]; 54: theme?: ThemeName; 55: tools?: Tools; 56: verbose: boolean; 57: }): React.ReactNode { 58: return <NotebookEditToolUseRejectedMessage notebook_path={input.notebook_path} cell_id={input.cell_id} new_source={input.new_source} cell_type={input.cell_type} edit_mode={input.edit_mode} verbose={verbose} />; 59: } 60: export function renderToolUseErrorMessage(result: ToolResultBlockParam['content'], { 61: verbose 62: }: { 63: verbose: boolean; 64: }): React.ReactNode { 65: if (!verbose && typeof result === 'string' && extractTag(result, 'tool_use_error')) { 66: return <MessageResponse> 67: <Text color="error">Error editing notebook</Text> 68: </MessageResponse>; 69: } 70: return <FallbackToolUseErrorMessage result={result} verbose={verbose} />; 71: } 72: export function renderToolResultMessage({ 73: cell_id, 74: new_source, 75: error 76: }: Output): React.ReactNode { 77: if (error) { 78: return <MessageResponse> 79: <Text color="error">{error}</Text> 80: </MessageResponse>; 81: } 82: return <MessageResponse> 83: <Box flexDirection="column"> 84: <Text> 85: Updated cell <Text bold>{cell_id}</Text>: 86: </Text> 87: <Box marginLeft={2}> 88: <HighlightedCode code={new_source} filePath="notebook.py" /> 89: </Box> 90: </Box> 91: </MessageResponse>; 92: }

File: src/tools/PowerShellTool/clmTypes.ts

typescript 1: export const CLM_ALLOWED_TYPES: ReadonlySet<string> = new Set( 2: [ 3: 'alias', 4: 'allowemptycollection', 5: 'allowemptystring', 6: 'allownull', 7: 'argumentcompleter', 8: 'argumentcompletions', 9: 'array', 10: 'bigint', 11: 'bool', 12: 'byte', 13: 'char', 14: 'cimclass', 15: 'cimconverter', 16: 'ciminstance', 17: 'cimtype', 18: 'cmdletbinding', 19: 'cultureinfo', 20: 'datetime', 21: 'decimal', 22: 'double', 23: 'dsclocalconfigurationmanager', 24: 'dscproperty', 25: 'dscresource', 26: 'experimentaction', 27: 'experimental', 28: 'experimentalfeature', 29: 'float', 30: 'guid', 31: 'hashtable', 32: 'int', 33: 'int16', 34: 'int32', 35: 'int64', 36: 'ipaddress', 37: 'ipendpoint', 38: 'long', 39: 'mailaddress', 40: 'norunspaceaffinity', 41: 'nullstring', 42: 'objectsecurity', 43: 'ordered', 44: 'outputtype', 45: 'parameter', 46: 'physicaladdress', 47: 'pscredential', 48: 'pscustomobject', 49: 'psdefaultvalue', 50: 'pslistmodifier', 51: 'psobject', 52: 'psprimitivedictionary', 53: 'pstypenameattribute', 54: 'ref', 55: 'regex', 56: 'sbyte', 57: 'securestring', 58: 'semver', 59: 'short', 60: 'single', 61: 'string', 62: 'supportswildcards', 63: 'switch', 64: 'timespan', 65: 'uint', 66: 'uint16', 67: 'uint32', 68: 'uint64', 69: 'ulong', 70: 'uri', 71: 'ushort', 72: 'validatecount', 73: 'validatedrive', 74: 'validatelength', 75: 'validatenotnull', 76: 'validatenotnullorempty', 77: 'validatenotnullorwhitespace', 78: 'validatepattern', 79: 'validaterange', 80: 'validatescript', 81: 'validateset', 82: 'validatetrusteddata', 83: 'validateuserdrive', 84: 'version', 85: 'void', 86: 'wildcardpattern', 87: 'x500distinguishedname', 88: 'x509certificate', 89: 'xml', 90: 'system.array', 91: 'system.boolean', 92: 'system.byte', 93: 'system.char', 94: 'system.datetime', 95: 'system.decimal', 96: 'system.double', 97: 'system.guid', 98: 'system.int16', 99: 'system.int32', 100: 'system.int64', 101: 'system.numerics.biginteger', 102: 'system.sbyte', 103: 'system.single', 104: 'system.string', 105: 'system.timespan', 106: 'system.uint16', 107: 'system.uint32', 108: 'system.uint64', 109: 'system.uri', 110: 'system.version', 111: 'system.void', 112: 'system.collections.hashtable', 113: 'system.text.regularexpressions.regex', 114: 'system.globalization.cultureinfo', 115: 'system.net.ipaddress', 116: 'system.net.ipendpoint', 117: 'system.net.mail.mailaddress', 118: 'system.net.networkinformation.physicaladdress', 119: 'system.security.securestring', 120: 'system.security.cryptography.x509certificates.x509certificate', 121: 'system.security.cryptography.x509certificates.x500distinguishedname', 122: 'system.xml.xmldocument', 123: 'system.management.automation.pscredential', 124: 'system.management.automation.pscustomobject', 125: 'system.management.automation.pslistmodifier', 126: 'system.management.automation.psobject', 127: 'system.management.automation.psprimitivedictionary', 128: 'system.management.automation.psreference', 129: 'system.management.automation.semanticversion', 130: 'system.management.automation.switchparameter', 131: 'system.management.automation.wildcardpattern', 132: 'system.management.automation.language.nullstring', 133: 'microsoft.management.infrastructure.cimclass', 134: 'microsoft.management.infrastructure.cimconverter', 135: 'microsoft.management.infrastructure.ciminstance', 136: 'microsoft.management.infrastructure.cimtype', 137: 'system.collections.specialized.ordereddictionary', 138: 'system.security.accesscontrol.objectsecurity', 139: 'object', 140: 'system.object', 141: 'microsoft.powershell.commands.modulespecification', 142: ].map(t => t.toLowerCase()), 143: ) 144: export function normalizeTypeName(name: string): string { 145: return name 146: .toLowerCase() 147: .replace(/\[\]$/, '') 148: .replace(/\[.*\]$/, '') 149: .trim() 150: } 151: /** 152: * True if typeName (from AST) is in Microsoft's CLM allowlist. 153: * Types NOT in this set trigger ask — they access system APIs CLM blocks. 154: */ 155: export function isClmAllowedType(typeName: string): boolean { 156: return CLM_ALLOWED_TYPES.has(normalizeTypeName(typeName)) 157: }

File: src/tools/PowerShellTool/commandSemantics.ts

typescript 1: export type CommandSemantic = ( 2: exitCode: number, 3: stdout: string, 4: stderr: string, 5: ) => { 6: isError: boolean 7: message?: string 8: } 9: const DEFAULT_SEMANTIC: CommandSemantic = (exitCode, _stdout, _stderr) => ({ 10: isError: exitCode !== 0, 11: message: 12: exitCode !== 0 ? `Command failed with exit code ${exitCode}` : undefined, 13: }) 14: const GREP_SEMANTIC: CommandSemantic = (exitCode, _stdout, _stderr) => ({ 15: isError: exitCode >= 2, 16: message: exitCode === 1 ? 'No matches found' : undefined, 17: }) 18: const COMMAND_SEMANTICS: Map<string, CommandSemantic> = new Map([ 19: ['grep', GREP_SEMANTIC], 20: ['rg', GREP_SEMANTIC], 21: ['findstr', GREP_SEMANTIC], 22: [ 23: 'robocopy', 24: (exitCode, _stdout, _stderr) => ({ 25: isError: exitCode >= 8, 26: message: 27: exitCode === 0 28: ? 'No files copied (already in sync)' 29: : exitCode >= 1 && exitCode < 8 30: ? exitCode & 1 31: ? 'Files copied successfully' 32: : 'Robocopy completed (no errors)' 33: : undefined, 34: }), 35: ], 36: ]) 37: function extractBaseCommand(segment: string): string { 38: const stripped = segment.trim().replace(/^[&.]\s+/, '') 39: const firstToken = stripped.split(/\s+/)[0] || '' 40: // Strip surrounding quotes if command was invoked as & "grep.exe" 41: const unquoted = firstToken.replace(/^["']|["']$/g, '') 42: // Strip path: C:\bin\grep.exe → grep.exe, .\rg.exe → rg.exe 43: const basename = unquoted.split(/[\\/]/).pop() || unquoted 44: // Strip .exe suffix (Windows is case-insensitive) 45: return basename.toLowerCase().replace(/\.exe$/, '') 46: } 47: /** 48: * Extract the primary command from a PowerShell command line. 49: * Takes the LAST pipeline segment since that determines the exit code. 50: * 51: * Heuristic split on `;` and `|` — may get it wrong for quoted strings or 52: * complex constructs. Do NOT depend on this for security; it's only used 53: * for exit-code interpretation (false negatives just fall back to default). 54: */ 55: function heuristicallyExtractBaseCommand(command: string): string { 56: const segments = command.split(/[;|]/).filter(s => s.trim()) 57: const last = segments[segments.length - 1] || command 58: return extractBaseCommand(last) 59: } 60: export function interpretCommandResult( 61: command: string, 62: exitCode: number, 63: stdout: string, 64: stderr: string, 65: ): { 66: isError: boolean 67: message?: string 68: } { 69: const baseCommand = heuristicallyExtractBaseCommand(command) 70: const semantic = COMMAND_SEMANTICS.get(baseCommand) ?? DEFAULT_SEMANTIC 71: return semantic(exitCode, stdout, stderr) 72: }

File: src/tools/PowerShellTool/commonParameters.ts

typescript 1: export const COMMON_SWITCHES = ['-verbose', '-debug'] 2: export const COMMON_VALUE_PARAMS = [ 3: '-erroraction', 4: '-warningaction', 5: '-informationaction', 6: '-progressaction', 7: '-errorvariable', 8: '-warningvariable', 9: '-informationvariable', 10: '-outvariable', 11: '-outbuffer', 12: '-pipelinevariable', 13: ] 14: export const COMMON_PARAMETERS: ReadonlySet<string> = new Set([ 15: ...COMMON_SWITCHES, 16: ...COMMON_VALUE_PARAMS, 17: ])

File: src/tools/PowerShellTool/destructiveCommandWarning.ts

typescript 1: type DestructivePattern = { 2: pattern: RegExp 3: warning: string 4: } 5: const DESTRUCTIVE_PATTERNS: DestructivePattern[] = [ 6: { 7: pattern: 8: /(?:^|[|;&\n({])\s*(Remove-Item|rm|del|rd|rmdir|ri)\b[^|;&\n}]*-Recurse\b[^|;&\n}]*-Force\b/i, 9: warning: 'Note: may recursively force-remove files', 10: }, 11: { 12: pattern: 13: /(?:^|[|;&\n({])\s*(Remove-Item|rm|del|rd|rmdir|ri)\b[^|;&\n}]*-Force\b[^|;&\n}]*-Recurse\b/i, 14: warning: 'Note: may recursively force-remove files', 15: }, 16: { 17: pattern: 18: /(?:^|[|;&\n({])\s*(Remove-Item|rm|del|rd|rmdir|ri)\b[^|;&\n}]*-Recurse\b/i, 19: warning: 'Note: may recursively remove files', 20: }, 21: { 22: pattern: 23: /(?:^|[|;&\n({])\s*(Remove-Item|rm|del|rd|rmdir|ri)\b[^|;&\n}]*-Force\b/i, 24: warning: 'Note: may force-remove files', 25: }, 26: { 27: pattern: /\bClear-Content\b[^|;&\n]*\*/i, 28: warning: 'Note: may clear content of multiple files', 29: }, 30: { 31: pattern: /\bFormat-Volume\b/i, 32: warning: 'Note: may format a disk volume', 33: }, 34: { 35: pattern: /\bClear-Disk\b/i, 36: warning: 'Note: may clear a disk', 37: }, 38: { 39: pattern: /\bgit\s+reset\s+--hard\b/i, 40: warning: 'Note: may discard uncommitted changes', 41: }, 42: { 43: pattern: /\bgit\s+push\b[^|;&\n]*\s+(--force|--force-with-lease|-f)\b/i, 44: warning: 'Note: may overwrite remote history', 45: }, 46: { 47: pattern: 48: /\bgit\s+clean\b(?![^|;&\n]*(?:-[a-zA-Z]*n|--dry-run))[^|;&\n]*-[a-zA-Z]*f/i, 49: warning: 'Note: may permanently delete untracked files', 50: }, 51: { 52: pattern: /\bgit\s+stash\s+(drop|clear)\b/i, 53: warning: 'Note: may permanently remove stashed changes', 54: }, 55: { 56: pattern: /\b(DROP|TRUNCATE)\s+(TABLE|DATABASE|SCHEMA)\b/i, 57: warning: 'Note: may drop or truncate database objects', 58: }, 59: { 60: pattern: /\bStop-Computer\b/i, 61: warning: 'Note: will shut down the computer', 62: }, 63: { 64: pattern: /\bRestart-Computer\b/i, 65: warning: 'Note: will restart the computer', 66: }, 67: { 68: pattern: /\bClear-RecycleBin\b/i, 69: warning: 'Note: permanently deletes recycled files', 70: }, 71: ] 72: export function getDestructiveCommandWarning(command: string): string | null { 73: for (const { pattern, warning } of DESTRUCTIVE_PATTERNS) { 74: if (pattern.test(command)) { 75: return warning 76: } 77: } 78: return null 79: }

File: src/tools/PowerShellTool/gitSafety.ts

typescript 1: import { basename, posix, resolve, sep } from 'path' 2: import { getCwd } from '../../utils/cwd.js' 3: import { PS_TOKENIZER_DASH_CHARS } from '../../utils/powershell/parser.js' 4: function resolveCwdReentry(normalized: string): string { 5: if (!normalized.startsWith('../')) return normalized 6: const cwdBase = basename(getCwd()).toLowerCase() 7: if (!cwdBase) return normalized 8: const prefix = '../' + cwdBase + '/' 9: let s = normalized 10: while (s.startsWith(prefix)) { 11: s = s.slice(prefix.length) 12: } 13: if (s === '../' + cwdBase) return '.' 14: return s 15: } 16: function normalizeGitPathArg(arg: string): string { 17: let s = arg 18: if (s.length > 0 && (PS_TOKENIZER_DASH_CHARS.has(s[0]!) || s[0] === '/')) { 19: const c = s.indexOf(':', 1) 20: if (c > 0) s = s.slice(c + 1) 21: } 22: s = s.replace(/^['"]|['"]$/g, '') 23: s = s.replace(/`/g, '') 24: // PS provider-qualified path: FileSystem::hooks/pre-commit → hooks/pre-commit 25: // Also handles fully-qualified form: Microsoft.PowerShell.Core\FileSystem::path 26: s = s.replace(/^(?:[A-Za-z0-9_.]+\\){0,3}FileSystem::/i, '') 27: // Drive-relative C:foo (no separator after colon) is cwd-relative on that 28: // drive. C:\foo (WITH separator) is absolute and must NOT match — the 29: // negative lookahead preserves it. 30: s = s.replace(/^[A-Za-z]:(?![/\\])/, '') 31: s = s.replace(/\\/g, '/') 32: // Win32 CreateFileW per-component: iteratively strip trailing spaces, 33: // then trailing dots, stopping if the result is `.` or `..` (special). 34: // `.. ` → `..`, `.. .` → `..`, `...` → '' → `.`, `hooks .` → `hooks`. 35: // Originally-'' (leading slash split) stays '' (absolute-path marker). 36: s = s 37: .split('/') 38: .map(c => { 39: if (c === '') return c 40: let prev 41: do { 42: prev = c 43: c = c.replace(/ +$/, '') 44: if (c === '.' || c === '..') return c 45: c = c.replace(/\.+$/, '') 46: } while (c !== prev) 47: return c || '.' 48: }) 49: .join('/') 50: s = posix.normalize(s) 51: if (s.startsWith('./')) s = s.slice(2) 52: return s.toLowerCase() 53: } 54: const GIT_INTERNAL_PREFIXES = ['head', 'objects', 'refs', 'hooks'] as const 55: /** 56: * SECURITY: Resolve a normalized path that escapes cwd (leading `../` or 57: * absolute) against the actual cwd, then check if it lands back INSIDE cwd. 58: * If so, strip cwd and return the cwd-relative remainder for prefix matching. 59: * If it lands outside cwd, return null (genuinely external — path-validation's 60: * concern). Covers `..\<cwd-basename>\HEAD` and `C:\<full-cwd>\HEAD` which 61: * posix.normalize alone cannot resolve (it leaves leading `..` as-is). 62: * 63: * This is the SOLE guard for the bare-repo HEAD attack. path-validation's 64: * DANGEROUS_FILES deliberately excludes bare `HEAD` (false-positive risk 65: * on legitimate non-git files named HEAD) and DANGEROUS_DIRECTORIES 66: * matches per-segment `.git` only — so `<cwd>/HEAD` passes that layer. 67: * The cwd-resolution here is load-bearing; do not remove without adding 68: * an alternative guard. 69: */ 70: function resolveEscapingPathToCwdRelative(n: string): string | null { 71: const cwd = getCwd() 72: // Reconstruct a platform-resolvable path from the posix-normalized form. 73: // `n` has forward slashes (normalizeGitPathArg converted \\ → /); resolve() 74: // handles forward slashes on Windows. 75: const abs = resolve(cwd, n) 76: const cwdWithSep = cwd.endsWith(sep) ? cwd : cwd + sep 77: // Case-insensitive comparison: normalizeGitPathArg lowercased `n`, so 78: // resolve() output has lowercase components from `n` but cwd may be 79: // mixed-case (e.g. C:\Users\...). Windows paths are case-insensitive. 80: const absLower = abs.toLowerCase() 81: const cwdLower = cwd.toLowerCase() 82: const cwdWithSepLower = cwdWithSep.toLowerCase() 83: if (absLower === cwdLower) return '.' 84: if (!absLower.startsWith(cwdWithSepLower)) return null 85: return abs.slice(cwdWithSep.length).replace(/\\/g, '/').toLowerCase() 86: } 87: function matchesGitInternalPrefix(n: string): boolean { 88: if (n === 'head' || n === '.git') return true 89: if (n.startsWith('.git/') || /^git~\d+($|\/)/.test(n)) return true 90: for (const p of GIT_INTERNAL_PREFIXES) { 91: if (p === 'head') continue 92: if (n === p || n.startsWith(p + '/')) return true 93: } 94: return false 95: } 96: /** 97: * True if arg (raw PS arg text) resolves to a git-internal path in cwd. 98: * Covers both bare-repo paths (hooks/, refs/) and standard-repo paths 99: * (.git/hooks/, .git/config). 100: */ 101: export function isGitInternalPathPS(arg: string): boolean { 102: const n = resolveCwdReentry(normalizeGitPathArg(arg)) 103: if (matchesGitInternalPrefix(n)) return true 104: // SECURITY: leading `../` or absolute paths that resolveCwdReentry and 105: // posix.normalize couldn't fully resolve. Resolve against actual cwd — if 106: // the result lands back in cwd at a git-internal location, the guard must 107: // still fire. 108: if (n.startsWith('../') || n.startsWith('/') || /^[a-z]:/.test(n)) { 109: const rel = resolveEscapingPathToCwdRelative(n) 110: if (rel !== null && matchesGitInternalPrefix(rel)) return true 111: } 112: return false 113: } 114: /** 115: * True if arg resolves to a path inside .git/ (standard-repo metadata dir). 116: * Unlike isGitInternalPathPS, does NOT match bare-repo-style root-level 117: * `hooks/`, `refs/` etc. — those are common project directory names. 118: */ 119: export function isDotGitPathPS(arg: string): boolean { 120: const n = resolveCwdReentry(normalizeGitPathArg(arg)) 121: if (matchesDotGitPrefix(n)) return true 122: // SECURITY: same cwd-resolution as isGitInternalPathPS — catch 123: // `..\<cwd-basename>\.git\hooks\pre-commit` that lands back in cwd. 124: if (n.startsWith('../') || n.startsWith('/') || /^[a-z]:/.test(n)) { 125: const rel = resolveEscapingPathToCwdRelative(n) 126: if (rel !== null && matchesDotGitPrefix(rel)) return true 127: } 128: return false 129: } 130: function matchesDotGitPrefix(n: string): boolean { 131: if (n === '.git' || n.startsWith('.git/')) return true 132: // NTFS 8.3 short names: .git becomes GIT~1 (or GIT~2, etc. if multiple 133: // dotfiles start with "git"). normalizeGitPathArg lowercases, so check 134: return /^git~\d+($|\/)/.test(n) 135: }

File: src/tools/PowerShellTool/modeValidation.ts

typescript 1: import type { ToolPermissionContext } from '../../Tool.js' 2: import type { PermissionResult } from '../../utils/permissions/PermissionResult.js' 3: import type { ParsedPowerShellCommand } from '../../utils/powershell/parser.js' 4: import { 5: deriveSecurityFlags, 6: getPipelineSegments, 7: PS_TOKENIZER_DASH_CHARS, 8: } from '../../utils/powershell/parser.js' 9: import { 10: argLeaksValue, 11: isAllowlistedPipelineTail, 12: isCwdChangingCmdlet, 13: isSafeOutputCommand, 14: resolveToCanonical, 15: } from './readOnlyValidation.js' 16: const ACCEPT_EDITS_ALLOWED_CMDLETS = new Set([ 17: 'set-content', 18: 'add-content', 19: 'remove-item', 20: 'clear-content', 21: ]) 22: function isAcceptEditsAllowedCmdlet(name: string): boolean { 23: const canonical = resolveToCanonical(name) 24: return ACCEPT_EDITS_ALLOWED_CMDLETS.has(canonical) 25: } 26: const LINK_ITEM_TYPES = new Set(['symboliclink', 'junction', 'hardlink']) 27: function isItemTypeParamAbbrev(p: string): boolean { 28: return ( 29: (p.length >= 3 && '-itemtype'.startsWith(p)) || 30: (p.length >= 3 && '-type'.startsWith(p)) 31: ) 32: } 33: export function isSymlinkCreatingCommand(cmd: { 34: name: string 35: args: string[] 36: }): boolean { 37: const canonical = resolveToCanonical(cmd.name) 38: if (canonical !== 'new-item') return false 39: for (let i = 0; i < cmd.args.length; i++) { 40: const raw = cmd.args[i] ?? '' 41: if (raw.length === 0) continue 42: // Normalize unicode dash prefixes (–, —, ―) and forward-slash (PS 5.1 43: // parameter prefix) → ASCII `-` so prefix comparison works. PS tokenizer 44: // treats all four dash chars plus `/` as parameter markers. (bug #26) 45: const normalized = 46: PS_TOKENIZER_DASH_CHARS.has(raw[0]!) || raw[0] === '/' 47: ? '-' + raw.slice(1) 48: : raw 49: const lower = normalized.toLowerCase() 50: // Split colon-bound value: -it:SymbolicLink → param='-it', val='symboliclink' 51: const colonIdx = lower.indexOf(':', 1) 52: const paramRaw = colonIdx > 0 ? lower.slice(0, colonIdx) : lower 53: const param = paramRaw.replace(/`/g, '') 54: if (!isItemTypeParamAbbrev(param)) continue 55: const rawVal = 56: colonIdx > 0 57: ? lower.slice(colonIdx + 1) 58: : (cmd.args[i + 1]?.toLowerCase() ?? '') 59: // Strip backtick escapes from colon-bound value: -it:Sym`bolicLink → symboliclink 60: const val = rawVal.replace(/`/g, '').replace(/^['"]|['"]$/g, '') 61: if (LINK_ITEM_TYPES.has(val)) return true 62: } 63: return false 64: } 65: /** 66: * Checks if commands should be handled differently based on the current permission mode. 67: * 68: * In acceptEdits mode, auto-allows filesystem-modifying PowerShell cmdlets. 69: * Uses the AST to resolve aliases before checking the allowlist. 70: * 71: * @param input - The PowerShell command input 72: * @param parsed - The parsed AST of the command 73: * @param toolPermissionContext - Context containing mode and permissions 74: * @returns 75: * - 'allow' if the current mode permits auto-approval 76: * - 'passthrough' if no mode-specific handling applies 77: */ 78: export function checkPermissionMode( 79: input: { command: string }, 80: parsed: ParsedPowerShellCommand, 81: toolPermissionContext: ToolPermissionContext, 82: ): PermissionResult { 83: // Skip bypass and dontAsk modes (handled elsewhere) 84: if ( 85: toolPermissionContext.mode === 'bypassPermissions' || 86: toolPermissionContext.mode === 'dontAsk' 87: ) { 88: return { 89: behavior: 'passthrough', 90: message: 'Mode is handled in main permission flow', 91: } 92: } 93: if (toolPermissionContext.mode !== 'acceptEdits') { 94: return { 95: behavior: 'passthrough', 96: message: 'No mode-specific validation required', 97: } 98: } 99: // acceptEdits mode: check if all commands are filesystem-modifying cmdlets 100: if (!parsed.valid) { 101: return { 102: behavior: 'passthrough', 103: message: 'Cannot validate mode for unparsed command', 104: } 105: } 106: // SECURITY: Check for subexpressions, script blocks, or member invocations 107: // that could be used to smuggle arbitrary code through acceptEdits mode. 108: const securityFlags = deriveSecurityFlags(parsed) 109: if ( 110: securityFlags.hasSubExpressions || 111: securityFlags.hasScriptBlocks || 112: securityFlags.hasMemberInvocations || 113: securityFlags.hasSplatting || 114: securityFlags.hasAssignments || 115: securityFlags.hasStopParsing || 116: securityFlags.hasExpandableStrings 117: ) { 118: return { 119: behavior: 'passthrough', 120: message: 121: 'Command contains subexpressions, script blocks, or member invocations that require approval', 122: } 123: } 124: const segments = getPipelineSegments(parsed) 125: // SECURITY: Empty segments with valid parse = no commands to check, don't auto-allow 126: if (segments.length === 0) { 127: return { 128: behavior: 'passthrough', 129: message: 'No commands found to validate for acceptEdits mode', 130: } 131: } 132: // SECURITY: Compound cwd desync guard — BashTool parity. 133: // When any statement in a compound contains Set-Location/Push-Location/Pop-Location 134: // (or aliases like cd, sl, chdir, pushd, popd), the cwd changes between statements. 135: // Path validation resolves relative paths against the stale process cwd, so a write 136: // cmdlet in a later statement targets a different directory than the validator checked. 137: // Example: `Set-Location ./.claude; Set-Content ./settings.json '...'` — the validator 138: // sees ./settings.json as /project/settings.json, but PowerShell writes to 139: // /project/.claude/settings.json. Refuse to auto-allow any write operation in a 140: // compound that contains a cwd-changing command. This matches BashTool's 141: // compoundCommandHasCd guard (BashTool/pathValidation.ts:630-655). 142: const totalCommands = segments.reduce( 143: (sum, seg) => sum + seg.commands.length, 144: 0, 145: ) 146: if (totalCommands > 1) { 147: let hasCdCommand = false 148: let hasSymlinkCreate = false 149: let hasWriteCommand = false 150: for (const seg of segments) { 151: for (const cmd of seg.commands) { 152: if (cmd.elementType !== 'CommandAst') continue 153: if (isCwdChangingCmdlet(cmd.name)) hasCdCommand = true 154: if (isSymlinkCreatingCommand(cmd)) hasSymlinkCreate = true 155: if (isAcceptEditsAllowedCmdlet(cmd.name)) hasWriteCommand = true 156: } 157: } 158: if (hasCdCommand && hasWriteCommand) { 159: return { 160: behavior: 'passthrough', 161: message: 162: 'Compound command contains a directory-changing command (Set-Location/Push-Location/Pop-Location) with a write operation — cannot auto-allow because path validation uses stale cwd', 163: } 164: } 165: // SECURITY: Link-create compound guard (finding #18). Mirrors the cd 166: // guard above. `New-Item -ItemType SymbolicLink -Path ./link -Value /etc; 167: if (hasSymlinkCreate) { 168: return { 169: behavior: 'passthrough', 170: message: 171: 'Compound command creates a filesystem link (New-Item -ItemType SymbolicLink/Junction/HardLink) — cannot auto-allow because path validation cannot follow just-created links', 172: } 173: } 174: } 175: for (const segment of segments) { 176: for (const cmd of segment.commands) { 177: if (cmd.elementType !== 'CommandAst') { 178: return { 179: behavior: 'passthrough', 180: message: `Pipeline contains expression source (${cmd.elementType}) that cannot be statically validated`, 181: } 182: } 183: if (cmd.nameType === 'application') { 184: return { 185: behavior: 'passthrough', 186: message: `Command '${cmd.name}' resolved from a path-like name and requires approval`, 187: } 188: } 189: if (cmd.elementTypes) { 190: for (let i = 1; i < cmd.elementTypes.length; i++) { 191: const t = cmd.elementTypes[i] 192: if (t !== 'StringConstant' && t !== 'Parameter') { 193: return { 194: behavior: 'passthrough', 195: message: `Command argument has unvalidatable type (${t}) — variable paths cannot be statically resolved`, 196: } 197: } 198: if (t === 'Parameter') { 199: const arg = cmd.args[i - 1] ?? '' 200: const colonIdx = arg.indexOf(':') 201: if (colonIdx > 0 && /[$(@{[]/.test(arg.slice(colonIdx + 1))) { 202: return { 203: behavior: 'passthrough', 204: message: 205: 'Colon-bound parameter contains an expression that cannot be statically validated', 206: } 207: } 208: } 209: } 210: } 211: if ( 212: isSafeOutputCommand(cmd.name) || 213: isAllowlistedPipelineTail(cmd, input.command) 214: ) { 215: continue 216: } 217: if (!isAcceptEditsAllowedCmdlet(cmd.name)) { 218: return { 219: behavior: 'passthrough', 220: message: `No mode-specific handling for '${cmd.name}' in acceptEdits mode`, 221: } 222: } 223: if (argLeaksValue(cmd.name, cmd)) { 224: return { 225: behavior: 'passthrough', 226: message: `Arguments in '${cmd.name}' cannot be statically validated in acceptEdits mode`, 227: } 228: } 229: } 230: if (segment.nestedCommands) { 231: for (const cmd of segment.nestedCommands) { 232: if (cmd.elementType !== 'CommandAst') { 233: return { 234: behavior: 'passthrough', 235: message: `Nested expression element (${cmd.elementType}) cannot be statically validated`, 236: } 237: } 238: if (cmd.nameType === 'application') { 239: return { 240: behavior: 'passthrough', 241: message: `Nested command '${cmd.name}' resolved from a path-like name and requires approval`, 242: } 243: } 244: if ( 245: isSafeOutputCommand(cmd.name) || 246: isAllowlistedPipelineTail(cmd, input.command) 247: ) { 248: continue 249: } 250: if (!isAcceptEditsAllowedCmdlet(cmd.name)) { 251: return { 252: behavior: 'passthrough', 253: message: `No mode-specific handling for '${cmd.name}' in acceptEdits mode`, 254: } 255: } 256: if (argLeaksValue(cmd.name, cmd)) { 257: return { 258: behavior: 'passthrough', 259: message: `Arguments in nested '${cmd.name}' cannot be statically validated in acceptEdits mode`, 260: } 261: } 262: } 263: } 264: } 265: return { 266: behavior: 'allow', 267: updatedInput: input, 268: decisionReason: { 269: type: 'mode', 270: mode: 'acceptEdits', 271: }, 272: } 273: }

File: src/tools/PowerShellTool/pathValidation.ts

typescript 1: import { homedir } from 'os' 2: import { isAbsolute, resolve } from 'path' 3: import type { ToolPermissionContext } from '../../Tool.js' 4: import type { PermissionRule } from '../../types/permissions.js' 5: import { getCwd } from '../../utils/cwd.js' 6: import { 7: getFsImplementation, 8: safeResolvePath, 9: } from '../../utils/fsOperations.js' 10: import { containsPathTraversal, getDirectoryForPath } from '../../utils/path.js' 11: import { 12: allWorkingDirectories, 13: checkEditableInternalPath, 14: checkPathSafetyForAutoEdit, 15: checkReadableInternalPath, 16: matchingRuleForInput, 17: pathInAllowedWorkingPath, 18: } from '../../utils/permissions/filesystem.js' 19: import type { PermissionResult } from '../../utils/permissions/PermissionResult.js' 20: import { createReadRuleSuggestion } from '../../utils/permissions/PermissionUpdate.js' 21: import type { PermissionUpdate } from '../../utils/permissions/PermissionUpdateSchema.js' 22: import { 23: isDangerousRemovalPath, 24: isPathInSandboxWriteAllowlist, 25: } from '../../utils/permissions/pathValidation.js' 26: import { getPlatform } from '../../utils/platform.js' 27: import type { 28: ParsedCommandElement, 29: ParsedPowerShellCommand, 30: } from '../../utils/powershell/parser.js' 31: import { 32: isNullRedirectionTarget, 33: isPowerShellParameter, 34: } from '../../utils/powershell/parser.js' 35: import { COMMON_SWITCHES, COMMON_VALUE_PARAMS } from './commonParameters.js' 36: import { resolveToCanonical } from './readOnlyValidation.js' 37: const MAX_DIRS_TO_LIST = 5 38: const GLOB_PATTERN_REGEX = /[*?[\]]/ 39: type FileOperationType = 'read' | 'write' | 'create' 40: type PathCheckResult = { 41: allowed: boolean 42: decisionReason?: import('../../utils/permissions/PermissionResult.js').PermissionDecisionReason 43: } 44: type ResolvedPathCheckResult = PathCheckResult & { 45: resolvedPath: string 46: } 47: type CmdletPathConfig = { 48: operationType: FileOperationType 49: pathParams: string[] 50: knownSwitches: string[] 51: knownValueParams: string[] 52: leafOnlyPathParams?: string[] 53: positionalSkip?: number 54: optionalWrite?: boolean 55: } 56: const CMDLET_PATH_CONFIG: Record<string, CmdletPathConfig> = { 57: 'set-content': { 58: operationType: 'write', 59: pathParams: ['-path', '-literalpath', '-pspath', '-lp'], 60: knownSwitches: [ 61: '-passthru', 62: '-force', 63: '-whatif', 64: '-confirm', 65: '-usetransaction', 66: '-nonewline', 67: '-asbytestream', 68: ], 69: knownValueParams: [ 70: '-value', 71: '-filter', 72: '-include', 73: '-exclude', 74: '-credential', 75: '-encoding', 76: '-stream', 77: ], 78: }, 79: 'add-content': { 80: operationType: 'write', 81: pathParams: ['-path', '-literalpath', '-pspath', '-lp'], 82: knownSwitches: [ 83: '-passthru', 84: '-force', 85: '-whatif', 86: '-confirm', 87: '-usetransaction', 88: '-nonewline', 89: '-asbytestream', 90: ], 91: knownValueParams: [ 92: '-value', 93: '-filter', 94: '-include', 95: '-exclude', 96: '-credential', 97: '-encoding', 98: '-stream', 99: ], 100: }, 101: 'remove-item': { 102: operationType: 'write', 103: pathParams: ['-path', '-literalpath', '-pspath', '-lp'], 104: knownSwitches: [ 105: '-recurse', 106: '-force', 107: '-whatif', 108: '-confirm', 109: '-usetransaction', 110: ], 111: knownValueParams: [ 112: '-filter', 113: '-include', 114: '-exclude', 115: '-credential', 116: '-stream', 117: ], 118: }, 119: 'clear-content': { 120: operationType: 'write', 121: pathParams: ['-path', '-literalpath', '-pspath', '-lp'], 122: knownSwitches: ['-force', '-whatif', '-confirm', '-usetransaction'], 123: knownValueParams: [ 124: '-filter', 125: '-include', 126: '-exclude', 127: '-credential', 128: '-stream', 129: ], 130: }, 131: 'out-file': { 132: operationType: 'write', 133: pathParams: ['-filepath', '-path', '-literalpath', '-pspath', '-lp'], 134: knownSwitches: [ 135: '-append', 136: '-force', 137: '-noclobber', 138: '-nonewline', 139: '-whatif', 140: '-confirm', 141: ], 142: knownValueParams: ['-inputobject', '-encoding', '-width'], 143: }, 144: 'tee-object': { 145: operationType: 'write', 146: pathParams: ['-filepath', '-path', '-literalpath', '-pspath', '-lp'], 147: knownSwitches: ['-append'], 148: knownValueParams: ['-inputobject', '-variable', '-encoding'], 149: }, 150: 'export-csv': { 151: operationType: 'write', 152: pathParams: ['-path', '-literalpath', '-pspath', '-lp'], 153: knownSwitches: [ 154: '-append', 155: '-force', 156: '-noclobber', 157: '-notypeinformation', 158: '-includetypeinformation', 159: '-useculture', 160: '-noheader', 161: '-whatif', 162: '-confirm', 163: ], 164: knownValueParams: [ 165: '-inputobject', 166: '-delimiter', 167: '-encoding', 168: '-quotefields', 169: '-usequotes', 170: ], 171: }, 172: 'export-clixml': { 173: operationType: 'write', 174: pathParams: ['-path', '-literalpath', '-pspath', '-lp'], 175: knownSwitches: ['-force', '-noclobber', '-whatif', '-confirm'], 176: knownValueParams: ['-inputobject', '-depth', '-encoding'], 177: }, 178: 'new-item': { 179: operationType: 'write', 180: pathParams: ['-path', '-literalpath', '-pspath', '-lp'], 181: leafOnlyPathParams: ['-name'], 182: knownSwitches: ['-force', '-whatif', '-confirm', '-usetransaction'], 183: knownValueParams: ['-itemtype', '-value', '-credential', '-type'], 184: }, 185: 'copy-item': { 186: operationType: 'write', 187: pathParams: ['-path', '-literalpath', '-pspath', '-lp', '-destination'], 188: knownSwitches: [ 189: '-container', 190: '-force', 191: '-passthru', 192: '-recurse', 193: '-whatif', 194: '-confirm', 195: '-usetransaction', 196: ], 197: knownValueParams: [ 198: '-filter', 199: '-include', 200: '-exclude', 201: '-credential', 202: '-fromsession', 203: '-tosession', 204: ], 205: }, 206: 'move-item': { 207: operationType: 'write', 208: pathParams: ['-path', '-literalpath', '-pspath', '-lp', '-destination'], 209: knownSwitches: [ 210: '-force', 211: '-passthru', 212: '-whatif', 213: '-confirm', 214: '-usetransaction', 215: ], 216: knownValueParams: ['-filter', '-include', '-exclude', '-credential'], 217: }, 218: 'rename-item': { 219: operationType: 'write', 220: pathParams: ['-path', '-literalpath', '-pspath', '-lp'], 221: knownSwitches: [ 222: '-force', 223: '-passthru', 224: '-whatif', 225: '-confirm', 226: '-usetransaction', 227: ], 228: knownValueParams: [ 229: '-newname', 230: '-credential', 231: '-filter', 232: '-include', 233: '-exclude', 234: ], 235: }, 236: 'set-item': { 237: operationType: 'write', 238: pathParams: ['-path', '-literalpath', '-pspath', '-lp'], 239: knownSwitches: [ 240: '-force', 241: '-passthru', 242: '-whatif', 243: '-confirm', 244: '-usetransaction', 245: ], 246: knownValueParams: [ 247: '-value', 248: '-credential', 249: '-filter', 250: '-include', 251: '-exclude', 252: ], 253: }, 254: 'get-content': { 255: operationType: 'read', 256: pathParams: ['-path', '-literalpath', '-pspath', '-lp'], 257: knownSwitches: [ 258: '-force', 259: '-usetransaction', 260: '-wait', 261: '-raw', 262: '-asbytestream', 263: ], 264: knownValueParams: [ 265: '-readcount', 266: '-totalcount', 267: '-tail', 268: '-first', 269: '-head', 270: '-last', 271: '-filter', 272: '-include', 273: '-exclude', 274: '-credential', 275: '-delimiter', 276: '-encoding', 277: '-stream', 278: ], 279: }, 280: 'get-childitem': { 281: operationType: 'read', 282: pathParams: ['-path', '-literalpath', '-pspath', '-lp'], 283: knownSwitches: [ 284: '-recurse', 285: '-force', 286: '-name', 287: '-usetransaction', 288: '-followsymlink', 289: '-directory', 290: '-file', 291: '-hidden', 292: '-readonly', 293: '-system', 294: ], 295: knownValueParams: [ 296: '-filter', 297: '-include', 298: '-exclude', 299: '-depth', 300: '-attributes', 301: '-credential', 302: ], 303: }, 304: 'get-item': { 305: operationType: 'read', 306: pathParams: ['-path', '-literalpath', '-pspath', '-lp'], 307: knownSwitches: ['-force', '-usetransaction'], 308: knownValueParams: [ 309: '-filter', 310: '-include', 311: '-exclude', 312: '-credential', 313: '-stream', 314: ], 315: }, 316: 'get-itemproperty': { 317: operationType: 'read', 318: pathParams: ['-path', '-literalpath', '-pspath', '-lp'], 319: knownSwitches: ['-usetransaction'], 320: knownValueParams: [ 321: '-name', 322: '-filter', 323: '-include', 324: '-exclude', 325: '-credential', 326: ], 327: }, 328: 'get-itempropertyvalue': { 329: operationType: 'read', 330: pathParams: ['-path', '-literalpath', '-pspath', '-lp'], 331: knownSwitches: ['-usetransaction'], 332: knownValueParams: [ 333: '-name', 334: '-filter', 335: '-include', 336: '-exclude', 337: '-credential', 338: ], 339: }, 340: 'get-filehash': { 341: operationType: 'read', 342: pathParams: ['-path', '-literalpath', '-pspath', '-lp'], 343: knownSwitches: [], 344: knownValueParams: ['-algorithm', '-inputstream'], 345: }, 346: 'get-acl': { 347: operationType: 'read', 348: pathParams: ['-path', '-literalpath', '-pspath', '-lp'], 349: knownSwitches: ['-audit', '-allcentralaccesspolicies', '-usetransaction'], 350: knownValueParams: ['-inputobject', '-filter', '-include', '-exclude'], 351: }, 352: 'format-hex': { 353: operationType: 'read', 354: pathParams: ['-path', '-literalpath', '-pspath', '-lp'], 355: knownSwitches: ['-raw'], 356: knownValueParams: [ 357: '-inputobject', 358: '-encoding', 359: '-count', 360: '-offset', 361: ], 362: }, 363: 'test-path': { 364: operationType: 'read', 365: pathParams: ['-path', '-literalpath', '-pspath', '-lp'], 366: knownSwitches: ['-isvalid', '-usetransaction'], 367: knownValueParams: [ 368: '-filter', 369: '-include', 370: '-exclude', 371: '-pathtype', 372: '-credential', 373: '-olderthan', 374: '-newerthan', 375: ], 376: }, 377: 'resolve-path': { 378: operationType: 'read', 379: pathParams: ['-path', '-literalpath', '-pspath', '-lp'], 380: knownSwitches: ['-relative', '-usetransaction', '-force'], 381: knownValueParams: ['-credential', '-relativebasepath'], 382: }, 383: 'convert-path': { 384: operationType: 'read', 385: pathParams: ['-path', '-literalpath', '-pspath', '-lp'], 386: knownSwitches: ['-usetransaction'], 387: knownValueParams: [], 388: }, 389: 'select-string': { 390: operationType: 'read', 391: pathParams: ['-path', '-literalpath', '-pspath', '-lp'], 392: knownSwitches: [ 393: '-simplematch', 394: '-casesensitive', 395: '-quiet', 396: '-list', 397: '-notmatch', 398: '-allmatches', 399: '-noemphasis', 400: '-raw', 401: ], 402: knownValueParams: [ 403: '-inputobject', 404: '-pattern', 405: '-include', 406: '-exclude', 407: '-encoding', 408: '-context', 409: '-culture', 410: ], 411: }, 412: 'set-location': { 413: operationType: 'read', 414: pathParams: ['-path', '-literalpath', '-pspath', '-lp'], 415: knownSwitches: ['-passthru', '-usetransaction'], 416: knownValueParams: ['-stackname'], 417: }, 418: 'push-location': { 419: operationType: 'read', 420: pathParams: ['-path', '-literalpath', '-pspath', '-lp'], 421: knownSwitches: ['-passthru', '-usetransaction'], 422: knownValueParams: ['-stackname'], 423: }, 424: 'pop-location': { 425: operationType: 'read', 426: pathParams: [], 427: knownSwitches: ['-passthru', '-usetransaction'], 428: knownValueParams: ['-stackname'], 429: }, 430: 'select-xml': { 431: operationType: 'read', 432: pathParams: ['-path', '-literalpath', '-pspath', '-lp'], 433: knownSwitches: [], 434: knownValueParams: ['-xml', '-content', '-xpath', '-namespace'], 435: }, 436: 'get-winevent': { 437: operationType: 'read', 438: pathParams: ['-path'], 439: knownSwitches: ['-force', '-oldest'], 440: knownValueParams: [ 441: '-listlog', 442: '-logname', 443: '-listprovider', 444: '-providername', 445: '-maxevents', 446: '-computername', 447: '-credential', 448: '-filterxpath', 449: '-filterxml', 450: '-filterhashtable', 451: ], 452: }, 453: 'invoke-webrequest': { 454: operationType: 'write', 455: pathParams: ['-outfile', '-infile'], 456: positionalSkip: 1, 457: optionalWrite: true, 458: knownSwitches: [ 459: '-allowinsecureredirect', 460: '-allowunencryptedauthentication', 461: '-disablekeepalive', 462: '-nobodyprogress', 463: '-passthru', 464: '-preservefileauthorizationmetadata', 465: '-resume', 466: '-skipcertificatecheck', 467: '-skipheadervalidation', 468: '-skiphttperrorcheck', 469: '-usebasicparsing', 470: '-usedefaultcredentials', 471: ], 472: knownValueParams: [ 473: '-uri', 474: '-method', 475: '-body', 476: '-contenttype', 477: '-headers', 478: '-maximumredirection', 479: '-maximumretrycount', 480: '-proxy', 481: '-proxycredential', 482: '-retryintervalsec', 483: '-sessionvariable', 484: '-timeoutsec', 485: '-token', 486: '-transferencoding', 487: '-useragent', 488: '-websession', 489: '-credential', 490: '-authentication', 491: '-certificate', 492: '-certificatethumbprint', 493: '-form', 494: '-httpversion', 495: ], 496: }, 497: 'invoke-restmethod': { 498: operationType: 'write', 499: pathParams: ['-outfile', '-infile'], 500: positionalSkip: 1, 501: optionalWrite: true, 502: knownSwitches: [ 503: '-allowinsecureredirect', 504: '-allowunencryptedauthentication', 505: '-disablekeepalive', 506: '-followrellink', 507: '-nobodyprogress', 508: '-passthru', 509: '-preservefileauthorizationmetadata', 510: '-resume', 511: '-skipcertificatecheck', 512: '-skipheadervalidation', 513: '-skiphttperrorcheck', 514: '-usebasicparsing', 515: '-usedefaultcredentials', 516: ], 517: knownValueParams: [ 518: '-uri', 519: '-method', 520: '-body', 521: '-contenttype', 522: '-headers', 523: '-maximumfollowrellink', 524: '-maximumredirection', 525: '-maximumretrycount', 526: '-proxy', 527: '-proxycredential', 528: '-responseheaderstvariable', 529: '-retryintervalsec', 530: '-sessionvariable', 531: '-statuscodevariable', 532: '-timeoutsec', 533: '-token', 534: '-transferencoding', 535: '-useragent', 536: '-websession', 537: '-credential', 538: '-authentication', 539: '-certificate', 540: '-certificatethumbprint', 541: '-form', 542: '-httpversion', 543: ], 544: }, 545: 'expand-archive': { 546: operationType: 'write', 547: pathParams: ['-path', '-literalpath', '-pspath', '-lp', '-destinationpath'], 548: knownSwitches: ['-force', '-passthru', '-whatif', '-confirm'], 549: knownValueParams: [], 550: }, 551: 'compress-archive': { 552: operationType: 'write', 553: pathParams: ['-path', '-literalpath', '-pspath', '-lp', '-destinationpath'], 554: knownSwitches: ['-force', '-update', '-passthru', '-whatif', '-confirm'], 555: knownValueParams: ['-compressionlevel'], 556: }, 557: 'set-itemproperty': { 558: operationType: 'write', 559: pathParams: ['-path', '-literalpath', '-pspath', '-lp'], 560: knownSwitches: [ 561: '-passthru', 562: '-force', 563: '-whatif', 564: '-confirm', 565: '-usetransaction', 566: ], 567: knownValueParams: [ 568: '-name', 569: '-value', 570: '-type', 571: '-filter', 572: '-include', 573: '-exclude', 574: '-credential', 575: '-inputobject', 576: ], 577: }, 578: 'new-itemproperty': { 579: operationType: 'write', 580: pathParams: ['-path', '-literalpath', '-pspath', '-lp'], 581: knownSwitches: ['-force', '-whatif', '-confirm', '-usetransaction'], 582: knownValueParams: [ 583: '-name', 584: '-value', 585: '-propertytype', 586: '-type', 587: '-filter', 588: '-include', 589: '-exclude', 590: '-credential', 591: ], 592: }, 593: 'remove-itemproperty': { 594: operationType: 'write', 595: pathParams: ['-path', '-literalpath', '-pspath', '-lp'], 596: knownSwitches: ['-force', '-whatif', '-confirm', '-usetransaction'], 597: knownValueParams: [ 598: '-name', 599: '-filter', 600: '-include', 601: '-exclude', 602: '-credential', 603: ], 604: }, 605: 'clear-item': { 606: operationType: 'write', 607: pathParams: ['-path', '-literalpath', '-pspath', '-lp'], 608: knownSwitches: ['-force', '-whatif', '-confirm', '-usetransaction'], 609: knownValueParams: ['-filter', '-include', '-exclude', '-credential'], 610: }, 611: 'export-alias': { 612: operationType: 'write', 613: pathParams: ['-path', '-literalpath', '-pspath', '-lp'], 614: knownSwitches: [ 615: '-append', 616: '-force', 617: '-noclobber', 618: '-passthru', 619: '-whatif', 620: '-confirm', 621: ], 622: knownValueParams: ['-name', '-description', '-scope', '-as'], 623: }, 624: } 625: function matchesParam(paramLower: string, paramList: string[]): boolean { 626: for (const p of paramList) { 627: if ( 628: p === paramLower || 629: (paramLower.length > 1 && p.startsWith(paramLower)) 630: ) { 631: return true 632: } 633: } 634: return false 635: } 636: function hasComplexColonValue(rawValue: string): boolean { 637: return ( 638: rawValue.includes(',') || 639: rawValue.startsWith('(') || 640: rawValue.startsWith('[') || 641: rawValue.includes('`') || 642: rawValue.includes('@(') || 643: rawValue.startsWith('@{') || 644: rawValue.includes('$') 645: ) 646: } 647: function formatDirectoryList(directories: string[]): string { 648: const dirCount = directories.length 649: if (dirCount <= MAX_DIRS_TO_LIST) { 650: return directories.map(dir => `'${dir}'`).join(', ') 651: } 652: const firstDirs = directories 653: .slice(0, MAX_DIRS_TO_LIST) 654: .map(dir => `'${dir}'`) 655: .join(', ') 656: return `${firstDirs}, and ${dirCount - MAX_DIRS_TO_LIST} more` 657: } 658: function expandTilde(filePath: string): string { 659: if ( 660: filePath === '~' || 661: filePath.startsWith('~/') || 662: filePath.startsWith('~\\') 663: ) { 664: return homedir() + filePath.slice(1) 665: } 666: return filePath 667: } 668: /** 669: * Checks the raw user-provided path (pre-realpath) for dangerous removal 670: * targets. safeResolvePath/realpathSync canonicalizes in ways that defeat 671: * isDangerousRemovalPath: on Windows '/' → 'C:\' (fails the === '/' check); 672: * on macOS homedir() may be under /var which realpathSync rewrites to 673: * /private/var (fails the === homedir() check). Checking the tilde-expanded, 674: * backslash-normalized form catches the dangerous shapes (/, ~, /etc, /usr) 675: * as the user typed them. 676: */ 677: export function isDangerousRemovalRawPath(filePath: string): boolean { 678: const expanded = expandTilde(filePath.replace(/^['"]|['"]$/g, '')).replace( 679: /\\/g, 680: '/', 681: ) 682: return isDangerousRemovalPath(expanded) 683: } 684: export function dangerousRemovalDeny(path: string): PermissionResult { 685: return { 686: behavior: 'deny', 687: message: `Remove-Item on system path '${path}' is blocked. This path is protected from removal.`, 688: decisionReason: { 689: type: 'other', 690: reason: 'Removal targets a protected system path', 691: }, 692: } 693: } 694: /** 695: * Checks if a resolved path is allowed for the given operation type. 696: * Mirrors the logic in BashTool/pathValidation.ts isPathAllowed. 697: */ 698: function isPathAllowed( 699: resolvedPath: string, 700: context: ToolPermissionContext, 701: operationType: FileOperationType, 702: precomputedPathsToCheck?: readonly string[], 703: ): PathCheckResult { 704: const permissionType = operationType === 'read' ? 'read' : 'edit' 705: // 1. Check deny rules first 706: const denyRule = matchingRuleForInput( 707: resolvedPath, 708: context, 709: permissionType, 710: 'deny', 711: ) 712: if (denyRule !== null) { 713: return { 714: allowed: false, 715: decisionReason: { type: 'rule', rule: denyRule }, 716: } 717: } 718: // 2. For write/create operations, check internal editable paths (plan files, scratchpad, agent memory, job dirs) 719: // This MUST come before checkPathSafetyForAutoEdit since .claude is a dangerous directory 720: // and internal editable paths live under ~/.claude/ — matching the ordering in 721: // checkWritePermissionForTool (filesystem.ts step 1.5) 722: if (operationType !== 'read') { 723: const internalEditResult = checkEditableInternalPath(resolvedPath, {}) 724: if (internalEditResult.behavior === 'allow') { 725: return { 726: allowed: true, 727: decisionReason: internalEditResult.decisionReason, 728: } 729: } 730: } 731: // 2.5. For write/create operations, check safety validations 732: if (operationType !== 'read') { 733: const safetyCheck = checkPathSafetyForAutoEdit( 734: resolvedPath, 735: precomputedPathsToCheck, 736: ) 737: if (!safetyCheck.safe) { 738: return { 739: allowed: false, 740: decisionReason: { 741: type: 'safetyCheck', 742: reason: safetyCheck.message, 743: classifierApprovable: safetyCheck.classifierApprovable, 744: }, 745: } 746: } 747: } 748: // 3. Check if path is in allowed working directory 749: const isInWorkingDir = pathInAllowedWorkingPath( 750: resolvedPath, 751: context, 752: precomputedPathsToCheck, 753: ) 754: if (isInWorkingDir) { 755: if (operationType === 'read' || context.mode === 'acceptEdits') { 756: return { allowed: true } 757: } 758: } 759: // 3.5. For read operations, check internal readable paths 760: if (operationType === 'read') { 761: const internalReadResult = checkReadableInternalPath(resolvedPath, {}) 762: if (internalReadResult.behavior === 'allow') { 763: return { 764: allowed: true, 765: decisionReason: internalReadResult.decisionReason, 766: } 767: } 768: } 769: // 3.7. For write/create operations to paths OUTSIDE the working directory, 770: // check the sandbox write allowlist. When the sandbox is enabled, users 771: // have explicitly configured writable directories (e.g. /tmp/claude/) — 772: // treat these as additional allowed write directories so redirects/Out-File/ 773: // New-Item don't prompt unnecessarily. Paths IN the working directory are 774: // excluded: the sandbox allowlist always seeds '.' (cwd), which would 775: // bypass the acceptEdits gate at step 3. 776: if ( 777: operationType !== 'read' && 778: !isInWorkingDir && 779: isPathInSandboxWriteAllowlist(resolvedPath) 780: ) { 781: return { 782: allowed: true, 783: decisionReason: { 784: type: 'other', 785: reason: 'Path is in sandbox write allowlist', 786: }, 787: } 788: } 789: // 4. Check allow rules 790: const allowRule = matchingRuleForInput( 791: resolvedPath, 792: context, 793: permissionType, 794: 'allow', 795: ) 796: if (allowRule !== null) { 797: return { 798: allowed: true, 799: decisionReason: { type: 'rule', rule: allowRule }, 800: } 801: } 802: // 5. Path is not allowed 803: return { allowed: false } 804: } 805: /** 806: * Best-effort deny check for paths obscured by :: or backtick syntax. 807: * ONLY checks deny rules — never auto-allows. If the stripped guess 808: * doesn't match a deny rule, we fall through to ask as before. 809: */ 810: function checkDenyRuleForGuessedPath( 811: strippedPath: string, 812: cwd: string, 813: toolPermissionContext: ToolPermissionContext, 814: operationType: FileOperationType, 815: ): { resolvedPath: string; rule: PermissionRule } | null { 816: // Red-team P7: null bytes make expandPath throw. Pre-existing but 817: // defend here since we're introducing a new call path. 818: if (!strippedPath || strippedPath.includes('\0')) return null 819: // Red-team P3: `~/.ssh/x strips to ~/.ssh/x but expandTilde only fires 820: // on leading ~ — the backtick was in front of it. Re-run here. 821: const tildeExpanded = expandTilde(strippedPath) 822: const abs = isAbsolute(tildeExpanded) 823: ? tildeExpanded 824: : resolve(cwd, tildeExpanded) 825: const { resolvedPath } = safeResolvePath(getFsImplementation(), abs) 826: const permissionType = operationType === 'read' ? 'read' : 'edit' 827: const denyRule = matchingRuleForInput( 828: resolvedPath, 829: toolPermissionContext, 830: permissionType, 831: 'deny', 832: ) 833: return denyRule ? { resolvedPath, rule: denyRule } : null 834: } 835: /** 836: * Validates a file system path, handling tilde expansion. 837: */ 838: function validatePath( 839: filePath: string, 840: cwd: string, 841: toolPermissionContext: ToolPermissionContext, 842: operationType: FileOperationType, 843: ): ResolvedPathCheckResult { 844: // Remove surrounding quotes if present 845: const cleanPath = expandTilde(filePath.replace(/^['"]|['"]$/g, '')) 846: // SECURITY: PowerShell Core normalizes backslashes to forward slashes on all 847: // platforms, but path.resolve on Linux/Mac treats them as literal characters. 848: // Normalize before resolution so traversal patterns like dir\..\..\etc\shadow 849: // are correctly detected. 850: const normalizedPath = cleanPath.replace(/\\/g, '/') 851: // SECURITY: Backtick (`) is PowerShell's escape character. It is a no-op in 852: if (normalizedPath.includes('`')) { 853: const backtickStripped = normalizedPath.replace(/`/g, '') 854: const denyHit = checkDenyRuleForGuessedPath( 855: backtickStripped, 856: cwd, 857: toolPermissionContext, 858: operationType, 859: ) 860: if (denyHit) { 861: return { 862: allowed: false, 863: resolvedPath: denyHit.resolvedPath, 864: decisionReason: { type: 'rule', rule: denyHit.rule }, 865: } 866: } 867: return { 868: allowed: false, 869: resolvedPath: normalizedPath, 870: decisionReason: { 871: type: 'other', 872: reason: 873: 'Backtick escape characters in paths cannot be statically validated and require manual approval', 874: }, 875: } 876: } 877: // SECURITY: Block module-qualified provider paths. PowerShell allows 878: // `Microsoft.PowerShell.Core\FileSystem::/etc/passwd` which resolves to 879: if (normalizedPath.includes('::')) { 880: const afterProvider = normalizedPath.slice(normalizedPath.indexOf('::') + 2) 881: const denyHit = checkDenyRuleForGuessedPath( 882: afterProvider, 883: cwd, 884: toolPermissionContext, 885: operationType, 886: ) 887: if (denyHit) { 888: return { 889: allowed: false, 890: resolvedPath: denyHit.resolvedPath, 891: decisionReason: { type: 'rule', rule: denyHit.rule }, 892: } 893: } 894: return { 895: allowed: false, 896: resolvedPath: normalizedPath, 897: decisionReason: { 898: type: 'other', 899: reason: 900: 'Module-qualified provider paths (::) cannot be statically validated and require manual approval', 901: }, 902: } 903: } 904: if ( 905: normalizedPath.startsWith('//') || 906: /DavWWWRoot/i.test(normalizedPath) || 907: /@SSL@/i.test(normalizedPath) 908: ) { 909: return { 910: allowed: false, 911: resolvedPath: normalizedPath, 912: decisionReason: { 913: type: 'other', 914: reason: 915: 'UNC paths are blocked because they can trigger network requests and credential leakage', 916: }, 917: } 918: } 919: if (normalizedPath.includes('$') || normalizedPath.includes('%')) { 920: return { 921: allowed: false, 922: resolvedPath: normalizedPath, 923: decisionReason: { 924: type: 'other', 925: reason: 'Variable expansion syntax in paths requires manual approval', 926: }, 927: } 928: } 929: const providerPathRegex = 930: getPlatform() === 'windows' ? /^[a-z0-9]{2,}:/i : /^[a-z0-9]+:/i 931: if (providerPathRegex.test(normalizedPath)) { 932: return { 933: allowed: false, 934: resolvedPath: normalizedPath, 935: decisionReason: { 936: type: 'other', 937: reason: `Path '${normalizedPath}' uses a non-filesystem provider and requires manual approval`, 938: }, 939: } 940: } 941: if (GLOB_PATTERN_REGEX.test(normalizedPath)) { 942: if (operationType === 'write' || operationType === 'create') { 943: return { 944: allowed: false, 945: resolvedPath: normalizedPath, 946: decisionReason: { 947: type: 'other', 948: reason: 949: 'Glob patterns are not allowed in write operations. Please specify an exact file path.', 950: }, 951: } 952: } 953: if (containsPathTraversal(normalizedPath)) { 954: const absolutePath = isAbsolute(normalizedPath) 955: ? normalizedPath 956: : resolve(cwd, normalizedPath) 957: const { resolvedPath, isCanonical } = safeResolvePath( 958: getFsImplementation(), 959: absolutePath, 960: ) 961: const result = isPathAllowed( 962: resolvedPath, 963: toolPermissionContext, 964: operationType, 965: isCanonical ? [resolvedPath] : undefined, 966: ) 967: return { 968: allowed: result.allowed, 969: resolvedPath, 970: decisionReason: result.decisionReason, 971: } 972: } 973: const basePath = getGlobBaseDirectory(normalizedPath) 974: const absoluteBasePath = isAbsolute(basePath) 975: ? basePath 976: : resolve(cwd, basePath) 977: const { resolvedPath } = safeResolvePath( 978: getFsImplementation(), 979: absoluteBasePath, 980: ) 981: const permissionType = operationType === 'read' ? 'read' : 'edit' 982: const denyRule = matchingRuleForInput( 983: resolvedPath, 984: toolPermissionContext, 985: permissionType, 986: 'deny', 987: ) 988: if (denyRule !== null) { 989: return { 990: allowed: false, 991: resolvedPath, 992: decisionReason: { type: 'rule', rule: denyRule }, 993: } 994: } 995: return { 996: allowed: false, 997: resolvedPath, 998: decisionReason: { 999: type: 'other', 1000: reason: 1001: 'Glob patterns in paths cannot be statically validated — symlinks inside the glob expansion are not examined. Requires manual approval.', 1002: }, 1003: } 1004: } 1005: const absolutePath = isAbsolute(normalizedPath) 1006: ? normalizedPath 1007: : resolve(cwd, normalizedPath) 1008: const { resolvedPath, isCanonical } = safeResolvePath( 1009: getFsImplementation(), 1010: absolutePath, 1011: ) 1012: const result = isPathAllowed( 1013: resolvedPath, 1014: toolPermissionContext, 1015: operationType, 1016: isCanonical ? [resolvedPath] : undefined, 1017: ) 1018: return { 1019: allowed: result.allowed, 1020: resolvedPath, 1021: decisionReason: result.decisionReason, 1022: } 1023: } 1024: function getGlobBaseDirectory(filePath: string): string { 1025: const globMatch = filePath.match(GLOB_PATTERN_REGEX) 1026: if (!globMatch || globMatch.index === undefined) { 1027: return filePath 1028: } 1029: const beforeGlob = filePath.substring(0, globMatch.index) 1030: const lastSepIndex = Math.max( 1031: beforeGlob.lastIndexOf('/'), 1032: beforeGlob.lastIndexOf('\\'), 1033: ) 1034: if (lastSepIndex === -1) return '.' 1035: return beforeGlob.substring(0, lastSepIndex + 1) || '/' 1036: } 1037: /** 1038: * Element types that are safe to extract as literal path strings. 1039: * 1040: * Only element types with statically-known string values are safe for path 1041: * extraction. Variable and ExpandableString have runtime-determined values — 1042: * even though they're defended downstream ($ detection in validatePath's 1043: * `includes('$')` check, and the hasExpandableStrings security flag), excluding 1044: * them here is defense-in-direct: fail-safe at the earliest gate rather than 1045: * relying on downstream checks to catch them. 1046: * 1047: * Any other type (e.g., 'Other' for ArrayLiteralExpressionAst, 'SubExpression', 1048: * 'ScriptBlock', 'Variable', 'ExpandableString') cannot be statically validated 1049: * and must force an ask. 1050: */ 1051: const SAFE_PATH_ELEMENT_TYPES = new Set<string>(['StringConstant', 'Parameter']) 1052: function extractPathsFromCommand(cmd: ParsedCommandElement): { 1053: paths: string[] 1054: operationType: FileOperationType 1055: hasUnvalidatablePathArg: boolean 1056: optionalWrite: boolean 1057: } { 1058: const canonical = resolveToCanonical(cmd.name) 1059: const config = CMDLET_PATH_CONFIG[canonical] 1060: if (!config) { 1061: return { 1062: paths: [], 1063: operationType: 'read', 1064: hasUnvalidatablePathArg: false, 1065: optionalWrite: false, 1066: } 1067: } 1068: const switchParams = [...config.knownSwitches, ...COMMON_SWITCHES] 1069: const valueParams = [...config.knownValueParams, ...COMMON_VALUE_PARAMS] 1070: const paths: string[] = [] 1071: const args = cmd.args 1072: const elementTypes = cmd.elementTypes 1073: let hasUnvalidatablePathArg = false 1074: let positionalsSeen = 0 1075: const positionalSkip = config.positionalSkip ?? 0 1076: function checkArgElementType(argIdx: number): void { 1077: if (!elementTypes) return 1078: const et = elementTypes[argIdx + 1] 1079: if (et && !SAFE_PATH_ELEMENT_TYPES.has(et)) { 1080: hasUnvalidatablePathArg = true 1081: } 1082: } 1083: for (let i = 0; i < args.length; i++) { 1084: const arg = args[i] 1085: if (!arg) continue 1086: const argElementType = elementTypes ? elementTypes[i + 1] : undefined 1087: if (isPowerShellParameter(arg, argElementType)) { 1088: const normalized = '-' + arg.slice(1) 1089: const colonIdx = normalized.indexOf(':', 1) 1090: const paramName = 1091: colonIdx > 0 ? normalized.substring(0, colonIdx) : normalized 1092: const paramLower = paramName.toLowerCase() 1093: if (matchesParam(paramLower, config.pathParams)) { 1094: let value: string | undefined 1095: if (colonIdx > 0) { 1096: const rawValue = arg.substring(colonIdx + 1) 1097: if (hasComplexColonValue(rawValue)) { 1098: hasUnvalidatablePathArg = true 1099: } else { 1100: value = rawValue 1101: } 1102: } else { 1103: const nextVal = args[i + 1] 1104: const nextType = elementTypes ? elementTypes[i + 2] : undefined 1105: if (nextVal && !isPowerShellParameter(nextVal, nextType)) { 1106: value = nextVal 1107: checkArgElementType(i + 1) 1108: i++ 1109: } 1110: } 1111: if (value) { 1112: paths.push(value) 1113: } 1114: } else if ( 1115: config.leafOnlyPathParams && 1116: matchesParam(paramLower, config.leafOnlyPathParams) 1117: ) { 1118: let value: string | undefined 1119: if (colonIdx > 0) { 1120: const rawValue = arg.substring(colonIdx + 1) 1121: if (hasComplexColonValue(rawValue)) { 1122: hasUnvalidatablePathArg = true 1123: } else { 1124: value = rawValue 1125: } 1126: } else { 1127: const nextVal = args[i + 1] 1128: const nextType = elementTypes ? elementTypes[i + 2] : undefined 1129: if (nextVal && !isPowerShellParameter(nextVal, nextType)) { 1130: value = nextVal 1131: checkArgElementType(i + 1) 1132: i++ 1133: } 1134: } 1135: if (value !== undefined) { 1136: if ( 1137: value.includes('/') || 1138: value.includes('\\') || 1139: value === '.' || 1140: value === '..' 1141: ) { 1142: // Non-leaf: separators or traversal. Can't resolve correctly 1143: hasUnvalidatablePathArg = true 1144: } else { 1145: paths.push(value) 1146: } 1147: } 1148: } else if (matchesParam(paramLower, switchParams)) { 1149: } else if (matchesParam(paramLower, valueParams)) { 1150: if (colonIdx > 0) { 1151: const rawValue = arg.substring(colonIdx + 1) 1152: if (hasComplexColonValue(rawValue)) { 1153: hasUnvalidatablePathArg = true 1154: } 1155: } else { 1156: const nextArg = args[i + 1] 1157: const nextArgType = elementTypes ? elementTypes[i + 2] : undefined 1158: if (nextArg && !isPowerShellParameter(nextArg, nextArgType)) { 1159: checkArgElementType(i + 1) 1160: i++ 1161: } 1162: } 1163: } else { 1164: hasUnvalidatablePathArg = true 1165: if (colonIdx > 0) { 1166: const rawValue = arg.substring(colonIdx + 1) 1167: if (!hasComplexColonValue(rawValue)) { 1168: paths.push(rawValue) 1169: } 1170: } 1171: } 1172: continue 1173: } 1174: if (positionalsSeen < positionalSkip) { 1175: positionalsSeen++ 1176: continue 1177: } 1178: positionalsSeen++ 1179: checkArgElementType(i) 1180: paths.push(arg) 1181: } 1182: return { 1183: paths, 1184: operationType: config.operationType, 1185: hasUnvalidatablePathArg, 1186: optionalWrite: config.optionalWrite ?? false, 1187: } 1188: } 1189: export function checkPathConstraints( 1190: input: { command: string }, 1191: parsed: ParsedPowerShellCommand, 1192: toolPermissionContext: ToolPermissionContext, 1193: compoundCommandHasCd = false, 1194: ): PermissionResult { 1195: if (!parsed.valid) { 1196: return { 1197: behavior: 'passthrough', 1198: message: 'Cannot validate paths for unparsed command', 1199: } 1200: } 1201: let firstAsk: PermissionResult | undefined 1202: for (const statement of parsed.statements) { 1203: const result = checkPathConstraintsForStatement( 1204: statement, 1205: toolPermissionContext, 1206: compoundCommandHasCd, 1207: ) 1208: if (result.behavior === 'deny') { 1209: return result 1210: } 1211: if (result.behavior === 'ask' && !firstAsk) { 1212: firstAsk = result 1213: } 1214: } 1215: return ( 1216: firstAsk ?? { 1217: behavior: 'passthrough', 1218: message: 'All path constraints validated successfully', 1219: } 1220: ) 1221: } 1222: function checkPathConstraintsForStatement( 1223: statement: ParsedPowerShellCommand['statements'][number], 1224: toolPermissionContext: ToolPermissionContext, 1225: compoundCommandHasCd = false, 1226: ): PermissionResult { 1227: const cwd = getCwd() 1228: let firstAsk: PermissionResult | undefined 1229: if (compoundCommandHasCd) { 1230: firstAsk = { 1231: behavior: 'ask', 1232: message: 1233: 'Compound command changes working directory (Set-Location/Push-Location/Pop-Location/New-PSDrive) — relative paths cannot be validated against the original cwd and require manual approval', 1234: decisionReason: { 1235: type: 'other', 1236: reason: 1237: 'Compound command contains cd with path operation — manual approval required to prevent path resolution bypass', 1238: }, 1239: } 1240: } 1241: let hasExpressionPipelineSource = false 1242: let pipelineSourceText: string | undefined 1243: for (const cmd of statement.commands) { 1244: if (cmd.elementType !== 'CommandAst') { 1245: hasExpressionPipelineSource = true 1246: pipelineSourceText = cmd.text 1247: continue 1248: } 1249: const { paths, operationType, hasUnvalidatablePathArg, optionalWrite } = 1250: extractPathsFromCommand(cmd) 1251: if (hasExpressionPipelineSource) { 1252: const canonical = resolveToCanonical(cmd.name) 1253: if (pipelineSourceText !== undefined) { 1254: const stripped = pipelineSourceText.replace(/^['"]|['"]$/g, '') 1255: const denyHit = checkDenyRuleForGuessedPath( 1256: stripped, 1257: cwd, 1258: toolPermissionContext, 1259: operationType, 1260: ) 1261: if (denyHit) { 1262: return { 1263: behavior: 'deny', 1264: message: `${canonical} targeting '${denyHit.resolvedPath}' was blocked by a deny rule`, 1265: decisionReason: { type: 'rule', rule: denyHit.rule }, 1266: } 1267: } 1268: } 1269: firstAsk ??= { 1270: behavior: 'ask', 1271: message: `${canonical} receives its path from a pipeline expression source that cannot be statically validated and requires manual approval`, 1272: } 1273: } 1274: if (hasUnvalidatablePathArg) { 1275: const canonical = resolveToCanonical(cmd.name) 1276: firstAsk ??= { 1277: behavior: 'ask', 1278: message: `${canonical} uses a parameter or complex path expression (array literal, subexpression, unknown parameter, etc.) that cannot be statically validated and requires manual approval`, 1279: } 1280: } 1281: if ( 1282: operationType !== 'read' && 1283: !optionalWrite && 1284: paths.length === 0 && 1285: CMDLET_PATH_CONFIG[resolveToCanonical(cmd.name)] 1286: ) { 1287: const canonical = resolveToCanonical(cmd.name) 1288: firstAsk ??= { 1289: behavior: 'ask', 1290: message: `${canonical} is a write operation but no target path could be determined; requires manual approval`, 1291: } 1292: continue 1293: } 1294: const isRemoval = resolveToCanonical(cmd.name) === 'remove-item' 1295: for (const filePath of paths) { 1296: if (isRemoval && isDangerousRemovalRawPath(filePath)) { 1297: return dangerousRemovalDeny(filePath) 1298: } 1299: const { allowed, resolvedPath, decisionReason } = validatePath( 1300: filePath, 1301: cwd, 1302: toolPermissionContext, 1303: operationType, 1304: ) 1305: if (isRemoval && isDangerousRemovalPath(resolvedPath)) { 1306: return dangerousRemovalDeny(resolvedPath) 1307: } 1308: if (!allowed) { 1309: const canonical = resolveToCanonical(cmd.name) 1310: const workingDirs = Array.from( 1311: allWorkingDirectories(toolPermissionContext), 1312: ) 1313: const dirListStr = formatDirectoryList(workingDirs) 1314: const message = 1315: decisionReason?.type === 'other' || 1316: decisionReason?.type === 'safetyCheck' 1317: ? decisionReason.reason 1318: : `${canonical} targeting '${resolvedPath}' was blocked. For security, Claude Code may only access files in the allowed working directories for this session: ${dirListStr}.` 1319: if (decisionReason?.type === 'rule') { 1320: return { 1321: behavior: 'deny', 1322: message, 1323: decisionReason, 1324: } 1325: } 1326: const suggestions: PermissionUpdate[] = [] 1327: if (resolvedPath) { 1328: if (operationType === 'read') { 1329: const suggestion = createReadRuleSuggestion( 1330: getDirectoryForPath(resolvedPath), 1331: 'session', 1332: ) 1333: if (suggestion) { 1334: suggestions.push(suggestion) 1335: } 1336: } else { 1337: suggestions.push({ 1338: type: 'addDirectories', 1339: directories: [getDirectoryForPath(resolvedPath)], 1340: destination: 'session', 1341: }) 1342: } 1343: } 1344: if (operationType === 'write' || operationType === 'create') { 1345: suggestions.push({ 1346: type: 'setMode', 1347: mode: 'acceptEdits', 1348: destination: 'session', 1349: }) 1350: } 1351: firstAsk ??= { 1352: behavior: 'ask', 1353: message, 1354: blockedPath: resolvedPath, 1355: decisionReason, 1356: suggestions, 1357: } 1358: } 1359: } 1360: } 1361: if (statement.nestedCommands) { 1362: for (const cmd of statement.nestedCommands) { 1363: const { paths, operationType, hasUnvalidatablePathArg, optionalWrite } = 1364: extractPathsFromCommand(cmd) 1365: if (hasUnvalidatablePathArg) { 1366: const canonical = resolveToCanonical(cmd.name) 1367: firstAsk ??= { 1368: behavior: 'ask', 1369: message: `${canonical} uses a parameter or complex path expression (array literal, subexpression, unknown parameter, etc.) that cannot be statically validated and requires manual approval`, 1370: } 1371: } 1372: if ( 1373: operationType !== 'read' && 1374: !optionalWrite && 1375: paths.length === 0 && 1376: CMDLET_PATH_CONFIG[resolveToCanonical(cmd.name)] 1377: ) { 1378: const canonical = resolveToCanonical(cmd.name) 1379: firstAsk ??= { 1380: behavior: 'ask', 1381: message: `${canonical} is a write operation but no target path could be determined; requires manual approval`, 1382: } 1383: continue 1384: } 1385: const isRemoval = resolveToCanonical(cmd.name) === 'remove-item' 1386: for (const filePath of paths) { 1387: if (isRemoval && isDangerousRemovalRawPath(filePath)) { 1388: return dangerousRemovalDeny(filePath) 1389: } 1390: const { allowed, resolvedPath, decisionReason } = validatePath( 1391: filePath, 1392: cwd, 1393: toolPermissionContext, 1394: operationType, 1395: ) 1396: if (isRemoval && isDangerousRemovalPath(resolvedPath)) { 1397: return dangerousRemovalDeny(resolvedPath) 1398: } 1399: if (!allowed) { 1400: const canonical = resolveToCanonical(cmd.name) 1401: const workingDirs = Array.from( 1402: allWorkingDirectories(toolPermissionContext), 1403: ) 1404: const dirListStr = formatDirectoryList(workingDirs) 1405: const message = 1406: decisionReason?.type === 'other' || 1407: decisionReason?.type === 'safetyCheck' 1408: ? decisionReason.reason 1409: : `${canonical} targeting '${resolvedPath}' was blocked. For security, Claude Code may only access files in the allowed working directories for this session: ${dirListStr}.` 1410: if (decisionReason?.type === 'rule') { 1411: return { 1412: behavior: 'deny', 1413: message, 1414: decisionReason, 1415: } 1416: } 1417: const suggestions: PermissionUpdate[] = [] 1418: if (resolvedPath) { 1419: if (operationType === 'read') { 1420: const suggestion = createReadRuleSuggestion( 1421: getDirectoryForPath(resolvedPath), 1422: 'session', 1423: ) 1424: if (suggestion) { 1425: suggestions.push(suggestion) 1426: } 1427: } else { 1428: suggestions.push({ 1429: type: 'addDirectories', 1430: directories: [getDirectoryForPath(resolvedPath)], 1431: destination: 'session', 1432: }) 1433: } 1434: } 1435: if (operationType === 'write' || operationType === 'create') { 1436: suggestions.push({ 1437: type: 'setMode', 1438: mode: 'acceptEdits', 1439: destination: 'session', 1440: }) 1441: } 1442: firstAsk ??= { 1443: behavior: 'ask', 1444: message, 1445: blockedPath: resolvedPath, 1446: decisionReason, 1447: suggestions, 1448: } 1449: } 1450: } 1451: if (hasExpressionPipelineSource) { 1452: firstAsk ??= { 1453: behavior: 'ask', 1454: message: `${resolveToCanonical(cmd.name)} appears inside a control-flow or chain statement where piped expression sources cannot be statically validated and requires manual approval`, 1455: } 1456: } 1457: } 1458: } 1459: if (statement.nestedCommands) { 1460: for (const cmd of statement.nestedCommands) { 1461: if (cmd.redirections) { 1462: for (const redir of cmd.redirections) { 1463: if (redir.isMerging) continue 1464: if (!redir.target) continue 1465: if (isNullRedirectionTarget(redir.target)) continue 1466: const { allowed, resolvedPath, decisionReason } = validatePath( 1467: redir.target, 1468: cwd, 1469: toolPermissionContext, 1470: 'create', 1471: ) 1472: if (!allowed) { 1473: const workingDirs = Array.from( 1474: allWorkingDirectories(toolPermissionContext), 1475: ) 1476: const dirListStr = formatDirectoryList(workingDirs) 1477: const message = 1478: decisionReason?.type === 'other' || 1479: decisionReason?.type === 'safetyCheck' 1480: ? decisionReason.reason 1481: : `Output redirection to '${resolvedPath}' was blocked. For security, Claude Code may only write to files in the allowed working directories for this session: ${dirListStr}.` 1482: if (decisionReason?.type === 'rule') { 1483: return { 1484: behavior: 'deny', 1485: message, 1486: decisionReason, 1487: } 1488: } 1489: firstAsk ??= { 1490: behavior: 'ask', 1491: message, 1492: blockedPath: resolvedPath, 1493: decisionReason, 1494: suggestions: [ 1495: { 1496: type: 'addDirectories', 1497: directories: [getDirectoryForPath(resolvedPath)], 1498: destination: 'session', 1499: }, 1500: ], 1501: } 1502: } 1503: } 1504: } 1505: } 1506: } 1507: if (statement.redirections) { 1508: for (const redir of statement.redirections) { 1509: if (redir.isMerging) continue 1510: if (!redir.target) continue 1511: if (isNullRedirectionTarget(redir.target)) continue 1512: const { allowed, resolvedPath, decisionReason } = validatePath( 1513: redir.target, 1514: cwd, 1515: toolPermissionContext, 1516: 'create', 1517: ) 1518: if (!allowed) { 1519: const workingDirs = Array.from( 1520: allWorkingDirectories(toolPermissionContext), 1521: ) 1522: const dirListStr = formatDirectoryList(workingDirs) 1523: const message = 1524: decisionReason?.type === 'other' || 1525: decisionReason?.type === 'safetyCheck' 1526: ? decisionReason.reason 1527: : `Output redirection to '${resolvedPath}' was blocked. For security, Claude Code may only write to files in the allowed working directories for this session: ${dirListStr}.` 1528: if (decisionReason?.type === 'rule') { 1529: return { 1530: behavior: 'deny', 1531: message, 1532: decisionReason, 1533: } 1534: } 1535: firstAsk ??= { 1536: behavior: 'ask', 1537: message, 1538: blockedPath: resolvedPath, 1539: decisionReason, 1540: suggestions: [ 1541: { 1542: type: 'addDirectories', 1543: directories: [getDirectoryForPath(resolvedPath)], 1544: destination: 'session', 1545: }, 1546: ], 1547: } 1548: } 1549: } 1550: } 1551: return ( 1552: firstAsk ?? { 1553: behavior: 'passthrough', 1554: message: 'All path constraints validated successfully', 1555: } 1556: ) 1557: }

File: src/tools/PowerShellTool/powershellPermissions.ts

typescript 1: import { resolve } from 'path' 2: import type { ToolPermissionContext, ToolUseContext } from '../../Tool.js' 3: import type { 4: PermissionDecisionReason, 5: PermissionResult, 6: } from '../../types/permissions.js' 7: import { getCwd } from '../../utils/cwd.js' 8: import { isCurrentDirectoryBareGitRepo } from '../../utils/git.js' 9: import type { PermissionRule } from '../../utils/permissions/PermissionRule.js' 10: import type { PermissionUpdate } from '../../utils/permissions/PermissionUpdateSchema.js' 11: import { 12: createPermissionRequestMessage, 13: getRuleByContentsForToolName, 14: } from '../../utils/permissions/permissions.js' 15: import { 16: matchWildcardPattern, 17: parsePermissionRule, 18: type ShellPermissionRule, 19: suggestionForExactCommand as sharedSuggestionForExactCommand, 20: } from '../../utils/permissions/shellRuleMatching.js' 21: import { 22: classifyCommandName, 23: deriveSecurityFlags, 24: getAllCommandNames, 25: getFileRedirections, 26: type ParsedCommandElement, 27: type ParsedPowerShellCommand, 28: PS_TOKENIZER_DASH_CHARS, 29: parsePowerShellCommand, 30: stripModulePrefix, 31: } from '../../utils/powershell/parser.js' 32: import { containsVulnerableUncPath } from '../../utils/shell/readOnlyCommandValidation.js' 33: import { isDotGitPathPS, isGitInternalPathPS } from './gitSafety.js' 34: import { 35: checkPermissionMode, 36: isSymlinkCreatingCommand, 37: } from './modeValidation.js' 38: import { 39: checkPathConstraints, 40: dangerousRemovalDeny, 41: isDangerousRemovalRawPath, 42: } from './pathValidation.js' 43: import { powershellCommandIsSafe } from './powershellSecurity.js' 44: import { 45: argLeaksValue, 46: isAllowlistedCommand, 47: isCwdChangingCmdlet, 48: isProvablySafeStatement, 49: isReadOnlyCommand, 50: isSafeOutputCommand, 51: resolveToCanonical, 52: } from './readOnlyValidation.js' 53: import { POWERSHELL_TOOL_NAME } from './toolName.js' 54: const PS_ASSIGN_PREFIX_RE = /^\$[\w:]+\s*(?:[+\-*/%]|\?\?)?\s*=\s*/ 55: const GIT_SAFETY_WRITE_CMDLETS = new Set([ 56: 'new-item', 57: 'set-content', 58: 'add-content', 59: 'out-file', 60: 'copy-item', 61: 'move-item', 62: 'rename-item', 63: 'expand-archive', 64: 'invoke-webrequest', 65: 'invoke-restmethod', 66: 'tee-object', 67: 'export-csv', 68: 'export-clixml', 69: ]) 70: const GIT_SAFETY_ARCHIVE_EXTRACTORS = new Set([ 71: 'tar', 72: 'tar.exe', 73: 'bsdtar', 74: 'bsdtar.exe', 75: 'unzip', 76: 'unzip.exe', 77: '7z', 78: '7z.exe', 79: '7za', 80: '7za.exe', 81: 'gzip', 82: 'gzip.exe', 83: 'gunzip', 84: 'gunzip.exe', 85: 'expand-archive', 86: ]) 87: async function extractCommandName(command: string): Promise<string> { 88: const trimmed = command.trim() 89: if (!trimmed) { 90: return '' 91: } 92: const parsed = await parsePowerShellCommand(trimmed) 93: const names = getAllCommandNames(parsed) 94: return names[0] ?? '' 95: } 96: /** 97: * Parse a permission rule string into a structured rule object. 98: * Delegates to shared parsePermissionRule. 99: */ 100: export function powershellPermissionRule( 101: permissionRule: string, 102: ): ShellPermissionRule { 103: return parsePermissionRule(permissionRule) 104: } 105: /** 106: * Generate permission update suggestion for exact command match. 107: * 108: * Skip exact-command suggestion for commands that can't round-trip cleanly: 109: * - Multi-line: newlines don't survive normalization, rule would never match 110: * - Literal *: storing `Remove-Item * -Force` verbatim re-parses as a wildcard 111: * rule via hasWildcards() (matches `^Remove-Item .* -Force$`). Escaping to 112: * `\*` creates a dead rule — parsePermissionRule's exact branch returns the 113: * raw string with backslash intact, so `Remove-Item \* -Force` never matches 114: * the incoming `Remove-Item * -Force`. Globs are unsafe to exact-auto-allow 115: * anyway; prefix suggestion still offered. (finding #12) 116: */ 117: function suggestionForExactCommand(command: string): PermissionUpdate[] { 118: if (command.includes('\n') || command.includes('*')) { 119: return [] 120: } 121: return sharedSuggestionForExactCommand(POWERSHELL_TOOL_NAME, command) 122: } 123: type PowerShellInput = { 124: command: string 125: timeout?: number 126: } 127: function filterRulesByContentsMatchingInput( 128: input: PowerShellInput, 129: rules: Map<string, PermissionRule>, 130: matchMode: 'exact' | 'prefix', 131: behavior: 'deny' | 'ask' | 'allow', 132: ): PermissionRule[] { 133: const command = input.command.trim() 134: function strEquals(a: string, b: string): boolean { 135: return a.toLowerCase() === b.toLowerCase() 136: } 137: function strStartsWith(str: string, prefix: string): boolean { 138: return str.toLowerCase().startsWith(prefix.toLowerCase()) 139: } 140: function stripModulePrefixForRule(name: string): string { 141: if (behavior === 'allow') { 142: return name 143: } 144: return stripModulePrefix(name) 145: } 146: const rawCmdName = command.split(/\s+/)[0] ?? '' 147: const inputCmdName = stripModulePrefix(rawCmdName) 148: const inputCanonical = resolveToCanonical(inputCmdName) 149: // Build a version of the command with the canonical name substituted 150: // e.g., 'rm foo.txt' -> 'remove-item foo.txt' so deny rules on Remove-Item also block rm. 151: const rest = command.slice(rawCmdName.length).replace(/^\s+/, ' ') 152: const canonicalCommand = inputCanonical + rest 153: return Array.from(rules.entries()) 154: .filter(([ruleContent]) => { 155: const rule = powershellPermissionRule(ruleContent) 156: function matchesCommand(cmd: string): boolean { 157: switch (rule.type) { 158: case 'exact': 159: return strEquals(rule.command, cmd) 160: case 'prefix': 161: switch (matchMode) { 162: case 'exact': 163: return strEquals(rule.prefix, cmd) 164: case 'prefix': { 165: if (strEquals(cmd, rule.prefix)) { 166: return true 167: } 168: return strStartsWith(cmd, rule.prefix + ' ') 169: } 170: } 171: break 172: case 'wildcard': 173: if (matchMode === 'exact') { 174: return false 175: } 176: return matchWildcardPattern(rule.pattern, cmd, true) 177: } 178: } 179: if (matchesCommand(command)) { 180: return true 181: } 182: if (matchesCommand(canonicalCommand)) { 183: return true 184: } 185: if (rule.type === 'exact') { 186: const rawRuleCmdName = rule.command.split(/\s+/)[0] ?? '' 187: const ruleCanonical = resolveToCanonical( 188: stripModulePrefixForRule(rawRuleCmdName), 189: ) 190: if (ruleCanonical === inputCanonical) { 191: // Rule and input resolve to same canonical cmdlet 192: // SECURITY: use normalized `rest` not a raw re-slice 193: // from `command`. The raw slice preserves tab separators so 194: // `Remove-Item\t./secret.txt` vs deny rule `rm ./secret.txt` misses. 195: // Normalize both sides identically. 196: const ruleRest = rule.command 197: .slice(rawRuleCmdName.length) 198: .replace(/^\s+/, ' ') 199: const inputRest = rest 200: if (strEquals(ruleRest, inputRest)) { 201: return true 202: } 203: } 204: } else if (rule.type === 'prefix') { 205: const rawRuleCmdName = rule.prefix.split(/\s+/)[0] ?? '' 206: const ruleCanonical = resolveToCanonical( 207: stripModulePrefixForRule(rawRuleCmdName), 208: ) 209: if (ruleCanonical === inputCanonical) { 210: const ruleRest = rule.prefix 211: .slice(rawRuleCmdName.length) 212: .replace(/^\s+/, ' ') 213: const canonicalPrefix = inputCanonical + ruleRest 214: if (matchMode === 'exact') { 215: if (strEquals(canonicalPrefix, canonicalCommand)) { 216: return true 217: } 218: } else { 219: if ( 220: strEquals(canonicalCommand, canonicalPrefix) || 221: strStartsWith(canonicalCommand, canonicalPrefix + ' ') 222: ) { 223: return true 224: } 225: } 226: } 227: } else if (rule.type === 'wildcard') { 228: const rawRuleCmdName = rule.pattern.split(/\s+/)[0] ?? '' 229: const ruleCanonical = resolveToCanonical( 230: stripModulePrefixForRule(rawRuleCmdName), 231: ) 232: if (ruleCanonical === inputCanonical && matchMode !== 'exact') { 233: const ruleRest = rule.pattern 234: .slice(rawRuleCmdName.length) 235: .replace(/^\s+/, ' ') 236: const canonicalPattern = inputCanonical + ruleRest 237: if (matchWildcardPattern(canonicalPattern, canonicalCommand, true)) { 238: return true 239: } 240: } 241: } 242: return false 243: }) 244: .map(([, rule]) => rule) 245: } 246: function matchingRulesForInput( 247: input: PowerShellInput, 248: toolPermissionContext: ToolPermissionContext, 249: matchMode: 'exact' | 'prefix', 250: ) { 251: const denyRuleByContents = getRuleByContentsForToolName( 252: toolPermissionContext, 253: POWERSHELL_TOOL_NAME, 254: 'deny', 255: ) 256: const matchingDenyRules = filterRulesByContentsMatchingInput( 257: input, 258: denyRuleByContents, 259: matchMode, 260: 'deny', 261: ) 262: const askRuleByContents = getRuleByContentsForToolName( 263: toolPermissionContext, 264: POWERSHELL_TOOL_NAME, 265: 'ask', 266: ) 267: const matchingAskRules = filterRulesByContentsMatchingInput( 268: input, 269: askRuleByContents, 270: matchMode, 271: 'ask', 272: ) 273: const allowRuleByContents = getRuleByContentsForToolName( 274: toolPermissionContext, 275: POWERSHELL_TOOL_NAME, 276: 'allow', 277: ) 278: const matchingAllowRules = filterRulesByContentsMatchingInput( 279: input, 280: allowRuleByContents, 281: matchMode, 282: 'allow', 283: ) 284: return { matchingDenyRules, matchingAskRules, matchingAllowRules } 285: } 286: export function powershellToolCheckExactMatchPermission( 287: input: PowerShellInput, 288: toolPermissionContext: ToolPermissionContext, 289: ): PermissionResult { 290: const trimmedCommand = input.command.trim() 291: const { matchingDenyRules, matchingAskRules, matchingAllowRules } = 292: matchingRulesForInput(input, toolPermissionContext, 'exact') 293: if (matchingDenyRules[0] !== undefined) { 294: return { 295: behavior: 'deny', 296: message: `Permission to use ${POWERSHELL_TOOL_NAME} with command ${trimmedCommand} has been denied.`, 297: decisionReason: { type: 'rule', rule: matchingDenyRules[0] }, 298: } 299: } 300: if (matchingAskRules[0] !== undefined) { 301: return { 302: behavior: 'ask', 303: message: createPermissionRequestMessage(POWERSHELL_TOOL_NAME), 304: decisionReason: { type: 'rule', rule: matchingAskRules[0] }, 305: } 306: } 307: if (matchingAllowRules[0] !== undefined) { 308: return { 309: behavior: 'allow', 310: updatedInput: input, 311: decisionReason: { type: 'rule', rule: matchingAllowRules[0] }, 312: } 313: } 314: const decisionReason: PermissionDecisionReason = { 315: type: 'other' as const, 316: reason: 'This command requires approval', 317: } 318: return { 319: behavior: 'passthrough', 320: message: createPermissionRequestMessage( 321: POWERSHELL_TOOL_NAME, 322: decisionReason, 323: ), 324: decisionReason, 325: suggestions: suggestionForExactCommand(trimmedCommand), 326: } 327: } 328: export function powershellToolCheckPermission( 329: input: PowerShellInput, 330: toolPermissionContext: ToolPermissionContext, 331: ): PermissionResult { 332: const command = input.command.trim() 333: const exactMatchResult = powershellToolCheckExactMatchPermission( 334: input, 335: toolPermissionContext, 336: ) 337: if ( 338: exactMatchResult.behavior === 'deny' || 339: exactMatchResult.behavior === 'ask' 340: ) { 341: return exactMatchResult 342: } 343: const { matchingDenyRules, matchingAskRules, matchingAllowRules } = 344: matchingRulesForInput(input, toolPermissionContext, 'prefix') 345: if (matchingDenyRules[0] !== undefined) { 346: return { 347: behavior: 'deny', 348: message: `Permission to use ${POWERSHELL_TOOL_NAME} with command ${command} has been denied.`, 349: decisionReason: { 350: type: 'rule', 351: rule: matchingDenyRules[0], 352: }, 353: } 354: } 355: if (matchingAskRules[0] !== undefined) { 356: return { 357: behavior: 'ask', 358: message: createPermissionRequestMessage(POWERSHELL_TOOL_NAME), 359: decisionReason: { 360: type: 'rule', 361: rule: matchingAskRules[0], 362: }, 363: } 364: } 365: if (exactMatchResult.behavior === 'allow') { 366: return exactMatchResult 367: } 368: if (matchingAllowRules[0] !== undefined) { 369: return { 370: behavior: 'allow', 371: updatedInput: input, 372: decisionReason: { 373: type: 'rule', 374: rule: matchingAllowRules[0], 375: }, 376: } 377: } 378: const decisionReason = { 379: type: 'other' as const, 380: reason: 'This command requires approval', 381: } 382: return { 383: behavior: 'passthrough', 384: message: createPermissionRequestMessage( 385: POWERSHELL_TOOL_NAME, 386: decisionReason, 387: ), 388: decisionReason, 389: suggestions: suggestionForExactCommand(command), 390: } 391: } 392: type SubCommandInfo = { 393: text: string 394: element: ParsedCommandElement 395: statement: ParsedPowerShellCommand['statements'][number] | null 396: isSafeOutput: boolean 397: } 398: async function getSubCommandsForPermissionCheck( 399: parsed: ParsedPowerShellCommand, 400: originalCommand: string, 401: ): Promise<SubCommandInfo[]> { 402: if (!parsed.valid) { 403: return [ 404: { 405: text: originalCommand, 406: element: { 407: name: await extractCommandName(originalCommand), 408: nameType: 'unknown', 409: elementType: 'CommandAst', 410: args: [], 411: text: originalCommand, 412: }, 413: statement: null, 414: isSafeOutput: false, 415: }, 416: ] 417: } 418: const subCommands: SubCommandInfo[] = [] 419: for (const statement of parsed.statements) { 420: for (const cmd of statement.commands) { 421: if (cmd.elementType !== 'CommandAst') { 422: continue 423: } 424: subCommands.push({ 425: text: cmd.text, 426: element: cmd, 427: statement, 428: isSafeOutput: 429: cmd.nameType !== 'application' && 430: isSafeOutputCommand(cmd.name) && 431: cmd.args.length === 0, 432: }) 433: } 434: if (statement.nestedCommands) { 435: for (const cmd of statement.nestedCommands) { 436: subCommands.push({ 437: text: cmd.text, 438: element: cmd, 439: statement, 440: isSafeOutput: 441: cmd.nameType !== 'application' && 442: isSafeOutputCommand(cmd.name) && 443: cmd.args.length === 0, 444: }) 445: } 446: } 447: } 448: if (subCommands.length > 0) { 449: return subCommands 450: } 451: return [ 452: { 453: text: originalCommand, 454: element: { 455: name: await extractCommandName(originalCommand), 456: nameType: 'unknown', 457: elementType: 'CommandAst', 458: args: [], 459: text: originalCommand, 460: }, 461: statement: null, 462: isSafeOutput: false, 463: }, 464: ] 465: } 466: export async function powershellToolHasPermission( 467: input: PowerShellInput, 468: context: ToolUseContext, 469: ): Promise<PermissionResult> { 470: const toolPermissionContext = context.getAppState().toolPermissionContext 471: const command = input.command.trim() 472: if (!command) { 473: return { 474: behavior: 'allow', 475: updatedInput: input, 476: decisionReason: { 477: type: 'other', 478: reason: 'Empty command is safe', 479: }, 480: } 481: } 482: const parsed = await parsePowerShellCommand(command) 483: const exactMatchResult = powershellToolCheckExactMatchPermission( 484: input, 485: toolPermissionContext, 486: ) 487: if (exactMatchResult.behavior === 'deny') { 488: return exactMatchResult 489: } 490: const { matchingDenyRules, matchingAskRules } = matchingRulesForInput( 491: input, 492: toolPermissionContext, 493: 'prefix', 494: ) 495: if (matchingDenyRules[0] !== undefined) { 496: return { 497: behavior: 'deny', 498: message: `Permission to use ${POWERSHELL_TOOL_NAME} with command ${command} has been denied.`, 499: decisionReason: { 500: type: 'rule', 501: rule: matchingDenyRules[0], 502: }, 503: } 504: } 505: let preParseAskDecision: PermissionResult | null = null 506: if (matchingAskRules[0] !== undefined) { 507: preParseAskDecision = { 508: behavior: 'ask', 509: message: createPermissionRequestMessage(POWERSHELL_TOOL_NAME), 510: decisionReason: { 511: type: 'rule', 512: rule: matchingAskRules[0], 513: }, 514: } 515: } 516: if (preParseAskDecision === null && containsVulnerableUncPath(command)) { 517: preParseAskDecision = { 518: behavior: 'ask', 519: message: 520: 'Command contains a UNC path that could trigger network requests', 521: } 522: } 523: if ( 524: exactMatchResult.behavior === 'allow' && 525: !parsed.valid && 526: preParseAskDecision === null && 527: classifyCommandName(command.split(/\s+/)[0] ?? '') !== 'application' 528: ) { 529: return exactMatchResult 530: } 531: if (!parsed.valid) { 532: const backtickStripped = command 533: .replace(/`[\r\n]+\s*/g, '') 534: .replace(/`/g, '') 535: for (const fragment of backtickStripped.split(/[;|\n\r{}()&]+/)) { 536: const trimmedFrag = fragment.trim() 537: if (!trimmedFrag) continue // skip empty fragments 538: // Skip the full command ONLY if it starts with a cmdlet name (no 539: // assignment prefix). The full command was already checked at 2a, but 540: // 2a uses the raw text — $x %= iex as first token `$x` misses the 541: // deny(iex:*) rule. If normalization would change the fragment 542: // (assignment prefix, dot-source), don't skip — let it be re-checked 543: if ( 544: trimmedFrag === command && 545: !/^\$[\w:]/.test(trimmedFrag) && 546: !/^[&.]\s/.test(trimmedFrag) 547: ) { 548: continue 549: } 550: let normalized = trimmedFrag 551: let m: RegExpMatchArray | null 552: while ((m = normalized.match(PS_ASSIGN_PREFIX_RE))) { 553: normalized = normalized.slice(m[0].length) 554: } 555: normalized = normalized.replace(/^[&.]\s+/, '') // & cmd, . cmd (dot-source) 556: const rawFirst = normalized.split(/\s+/)[0] ?? '' 557: const firstTok = rawFirst.replace(/^['"]|['"]$/g, '') 558: const normalizedFrag = firstTok + normalized.slice(rawFirst.length) 559: // SECURITY: parse-independent dangerous-removal hard-deny. The 560: // isDangerousRemovalPath check in checkPathConstraintsForStatement 561: // requires a valid AST; when pwsh times out or is unavailable, 562: // `Remove-Item /` degrades from hard-deny to generic ask. Check 563: // raw positional args here so root/home/system deletion is denied 564: // regardless of parser availability. Conservative: only positional 565: // args (skip -Param tokens); over-deny in degraded state is safe 566: // (same deny-downgrade rationale as the sub-command scan above). 567: if (resolveToCanonical(firstTok) === 'remove-item') { 568: for (const arg of normalized.split(/\s+/).slice(1)) { 569: if (PS_TOKENIZER_DASH_CHARS.has(arg[0] ?? '')) continue 570: if (isDangerousRemovalRawPath(arg)) { 571: return dangerousRemovalDeny(arg) 572: } 573: } 574: } 575: const { matchingDenyRules: fragDenyRules } = matchingRulesForInput( 576: { command: normalizedFrag }, 577: toolPermissionContext, 578: 'prefix', 579: ) 580: if (fragDenyRules[0] !== undefined) { 581: return { 582: behavior: 'deny', 583: message: `Permission to use ${POWERSHELL_TOOL_NAME} with command ${command} has been denied.`, 584: decisionReason: { type: 'rule', rule: fragDenyRules[0] }, 585: } 586: } 587: } 588: if (preParseAskDecision !== null) { 589: return preParseAskDecision 590: } 591: const decisionReason = { 592: type: 'other' as const, 593: reason: `Command contains malformed syntax that cannot be parsed: ${parsed.errors[0]?.message ?? 'unknown error'}`, 594: } 595: return { 596: behavior: 'ask', 597: decisionReason, 598: message: createPermissionRequestMessage( 599: POWERSHELL_TOOL_NAME, 600: decisionReason, 601: ), 602: } 603: } 604: const allSubCommands = await getSubCommandsForPermissionCheck(parsed, command) 605: const decisions: PermissionResult[] = [] 606: if (preParseAskDecision !== null) { 607: decisions.push(preParseAskDecision) 608: } 609: const safetyResult = powershellCommandIsSafe(command, parsed) 610: if (safetyResult.behavior !== 'passthrough') { 611: const decisionReason: PermissionDecisionReason = { 612: type: 'other' as const, 613: reason: 614: safetyResult.behavior === 'ask' && safetyResult.message 615: ? safetyResult.message 616: : 'This command contains patterns that could pose security risks and requires approval', 617: } 618: decisions.push({ 619: behavior: 'ask', 620: message: createPermissionRequestMessage( 621: POWERSHELL_TOOL_NAME, 622: decisionReason, 623: ), 624: decisionReason, 625: suggestions: suggestionForExactCommand(command), 626: }) 627: } 628: if (parsed.hasUsingStatements) { 629: const decisionReason: PermissionDecisionReason = { 630: type: 'other' as const, 631: reason: 632: 'Command contains a `using` statement that may load external code (module or assembly)', 633: } 634: decisions.push({ 635: behavior: 'ask', 636: message: createPermissionRequestMessage( 637: POWERSHELL_TOOL_NAME, 638: decisionReason, 639: ), 640: decisionReason, 641: suggestions: suggestionForExactCommand(command), 642: }) 643: } 644: if (parsed.hasScriptRequirements) { 645: const decisionReason: PermissionDecisionReason = { 646: type: 'other' as const, 647: reason: 648: 'Command contains a `#Requires` directive that may trigger module loading', 649: } 650: decisions.push({ 651: behavior: 'ask', 652: message: createPermissionRequestMessage( 653: POWERSHELL_TOOL_NAME, 654: decisionReason, 655: ), 656: decisionReason, 657: suggestions: suggestionForExactCommand(command), 658: }) 659: } 660: const NON_FS_PROVIDER_PATTERN = 661: /^(?:[\w.]+\\)?(env|hklm|hkcu|function|alias|variable|cert|wsman|registry)::?/i 662: function extractProviderPathFromArg(arg: string): string { 663: let s = arg 664: if (s.length > 0 && PS_TOKENIZER_DASH_CHARS.has(s[0]!)) { 665: const colonIdx = s.indexOf(':', 1) 666: if (colonIdx > 0) { 667: s = s.substring(colonIdx + 1) 668: } 669: } 670: return s.replace(/`/g, '') 671: } 672: function providerOrUncDecisionForArg(arg: string): PermissionResult | null { 673: const value = extractProviderPathFromArg(arg) 674: if (NON_FS_PROVIDER_PATTERN.test(value)) { 675: return { 676: behavior: 'ask', 677: message: `Command argument '${arg}' uses a non-filesystem provider path and requires approval`, 678: } 679: } 680: if (containsVulnerableUncPath(value)) { 681: return { 682: behavior: 'ask', 683: message: `Command argument '${arg}' contains a UNC path that could trigger network requests`, 684: } 685: } 686: return null 687: } 688: providerScan: for (const statement of parsed.statements) { 689: for (const cmd of statement.commands) { 690: if (cmd.elementType !== 'CommandAst') continue 691: for (const arg of cmd.args) { 692: const decision = providerOrUncDecisionForArg(arg) 693: if (decision !== null) { 694: decisions.push(decision) 695: break providerScan 696: } 697: } 698: } 699: if (statement.nestedCommands) { 700: for (const cmd of statement.nestedCommands) { 701: for (const arg of cmd.args) { 702: const decision = providerOrUncDecisionForArg(arg) 703: if (decision !== null) { 704: decisions.push(decision) 705: break providerScan 706: } 707: } 708: } 709: } 710: } 711: for (const { text: subCmd, element } of allSubCommands) { 712: const canonicalSubCmd = 713: element.name !== '' ? [element.name, ...element.args].join(' ') : null 714: const subInput = { command: subCmd } 715: const { matchingDenyRules: subDenyRules, matchingAskRules: subAskRules } = 716: matchingRulesForInput(subInput, toolPermissionContext, 'prefix') 717: let matchedDenyRule = subDenyRules[0] 718: let matchedAskRule = subAskRules[0] 719: if (matchedDenyRule === undefined && canonicalSubCmd !== null) { 720: const { 721: matchingDenyRules: canonicalDenyRules, 722: matchingAskRules: canonicalAskRules, 723: } = matchingRulesForInput( 724: { command: canonicalSubCmd }, 725: toolPermissionContext, 726: 'prefix', 727: ) 728: matchedDenyRule = canonicalDenyRules[0] 729: if (matchedAskRule === undefined) { 730: matchedAskRule = canonicalAskRules[0] 731: } 732: } 733: if (matchedDenyRule !== undefined) { 734: decisions.push({ 735: behavior: 'deny', 736: message: `Permission to use ${POWERSHELL_TOOL_NAME} with command ${command} has been denied.`, 737: decisionReason: { 738: type: 'rule', 739: rule: matchedDenyRule, 740: }, 741: }) 742: } else if (matchedAskRule !== undefined) { 743: decisions.push({ 744: behavior: 'ask', 745: message: createPermissionRequestMessage(POWERSHELL_TOOL_NAME), 746: decisionReason: { 747: type: 'rule', 748: rule: matchedAskRule, 749: }, 750: }) 751: } 752: } 753: const hasCdSubCommand = 754: allSubCommands.length > 1 && 755: allSubCommands.some(({ element }) => isCwdChangingCmdlet(element.name)) 756: const hasSymlinkCreate = 757: allSubCommands.length > 1 && 758: allSubCommands.some(({ element }) => isSymlinkCreatingCommand(element)) 759: const hasGitSubCommand = allSubCommands.some( 760: ({ element }) => resolveToCanonical(element.name) === 'git', 761: ) 762: if (hasCdSubCommand && hasGitSubCommand) { 763: decisions.push({ 764: behavior: 'ask', 765: message: 766: 'Compound commands with cd/Set-Location and git require approval to prevent bare repository attacks', 767: }) 768: } 769: if (hasGitSubCommand && isCurrentDirectoryBareGitRepo()) { 770: decisions.push({ 771: behavior: 'ask', 772: message: 773: 'Git command in a directory with bare-repository indicators (HEAD, objects/, refs/ in cwd without .git/HEAD). Git may execute hooks from cwd.', 774: }) 775: } 776: if (hasGitSubCommand) { 777: const writesToGitInternal = allSubCommands.some( 778: ({ element, statement }) => { 779: for (const r of element.redirections ?? []) { 780: if (isGitInternalPathPS(r.target)) return true 781: } 782: const canonical = resolveToCanonical(element.name) 783: if (!GIT_SAFETY_WRITE_CMDLETS.has(canonical)) return false 784: if ( 785: element.args 786: .flatMap(a => a.split(',')) 787: .some(a => isGitInternalPathPS(a)) 788: ) { 789: return true 790: } 791: if (statement !== null) { 792: for (const c of statement.commands) { 793: if (c.elementType === 'CommandAst') continue 794: if (isGitInternalPathPS(c.text)) return true 795: } 796: } 797: return false 798: }, 799: ) 800: const redirWritesToGitInternal = getFileRedirections(parsed).some(r => 801: isGitInternalPathPS(r.target), 802: ) 803: if (writesToGitInternal || redirWritesToGitInternal) { 804: decisions.push({ 805: behavior: 'ask', 806: message: 807: 'Command writes to a git-internal path (HEAD, objects/, refs/, hooks/, .git/) and runs git. This could plant a malicious hook that git then executes.', 808: }) 809: } 810: const hasArchiveExtractor = allSubCommands.some(({ element }) => 811: GIT_SAFETY_ARCHIVE_EXTRACTORS.has(element.name.toLowerCase()), 812: ) 813: if (hasArchiveExtractor) { 814: decisions.push({ 815: behavior: 'ask', 816: message: 817: 'Compound command extracts an archive and runs git. Archive contents may plant bare-repository indicators (HEAD, hooks/, refs/) that git then treats as the repository root.', 818: }) 819: } 820: } 821: { 822: const found = 823: allSubCommands.some(({ element }) => { 824: for (const r of element.redirections ?? []) { 825: if (isDotGitPathPS(r.target)) return true 826: } 827: const canonical = resolveToCanonical(element.name) 828: if (!GIT_SAFETY_WRITE_CMDLETS.has(canonical)) return false 829: return element.args.flatMap(a => a.split(',')).some(isDotGitPathPS) 830: }) || getFileRedirections(parsed).some(r => isDotGitPathPS(r.target)) 831: if (found) { 832: decisions.push({ 833: behavior: 'ask', 834: message: 835: 'Command writes to .git/ — hooks or config planted there execute on the next git operation.', 836: }) 837: } 838: } 839: const pathResult = checkPathConstraints( 840: input, 841: parsed, 842: toolPermissionContext, 843: hasCdSubCommand, 844: ) 845: if (pathResult.behavior !== 'passthrough') { 846: decisions.push(pathResult) 847: } 848: if ( 849: exactMatchResult.behavior === 'allow' && 850: allSubCommands[0] !== undefined && 851: allSubCommands.every( 852: sc => 853: sc.element.nameType !== 'application' && 854: !argLeaksValue(sc.text, sc.element), 855: ) 856: ) { 857: decisions.push(exactMatchResult) 858: } 859: if (isReadOnlyCommand(command, parsed)) { 860: decisions.push({ 861: behavior: 'allow', 862: updatedInput: input, 863: decisionReason: { 864: type: 'other', 865: reason: 'Command is read-only and safe to execute', 866: }, 867: }) 868: } 869: const fileRedirections = getFileRedirections(parsed) 870: if (fileRedirections.length > 0) { 871: decisions.push({ 872: behavior: 'ask', 873: message: 874: 'Command contains file redirections that could write to arbitrary paths', 875: suggestions: suggestionForExactCommand(command), 876: }) 877: } 878: const modeResult = checkPermissionMode(input, parsed, toolPermissionContext) 879: if (modeResult.behavior !== 'passthrough') { 880: decisions.push(modeResult) 881: } 882: const deniedDecision = decisions.find(d => d.behavior === 'deny') 883: if (deniedDecision !== undefined) { 884: return deniedDecision 885: } 886: const askDecision = decisions.find(d => d.behavior === 'ask') 887: if (askDecision !== undefined) { 888: return askDecision 889: } 890: const allowDecision = decisions.find(d => d.behavior === 'allow') 891: if (allowDecision !== undefined) { 892: return allowDecision 893: } 894: const subCommands = allSubCommands.filter(({ element, isSafeOutput }) => { 895: if (isSafeOutput) { 896: return false 897: } 898: if (element.nameType === 'application') { 899: return true 900: } 901: const canonical = resolveToCanonical(element.name) 902: if (canonical === 'set-location' && element.args.length > 0) { 903: const target = element.args.find( 904: a => a.length === 0 || !PS_TOKENIZER_DASH_CHARS.has(a[0]!), 905: ) 906: if (target && resolve(getCwd(), target) === getCwd()) { 907: return false 908: } 909: } 910: return true 911: }) 912: const subCommandsNeedingApproval: string[] = [] 913: const statementsSeenInLoop = new Set< 914: ParsedPowerShellCommand['statements'][number] 915: >() 916: for (const { text: subCmd, element, statement } of subCommands) { 917: const subInput = { command: subCmd } 918: const subResult = powershellToolCheckPermission( 919: subInput, 920: toolPermissionContext, 921: ) 922: if (subResult.behavior === 'deny') { 923: return { 924: behavior: 'deny', 925: message: `Permission to use ${POWERSHELL_TOOL_NAME} with command ${command} has been denied.`, 926: decisionReason: subResult.decisionReason, 927: } 928: } 929: if (subResult.behavior === 'ask') { 930: if (statement !== null) { 931: statementsSeenInLoop.add(statement) 932: } 933: subCommandsNeedingApproval.push(subCmd) 934: continue 935: } 936: if ( 937: subResult.behavior === 'allow' && 938: element.nameType !== 'application' && 939: !hasSymlinkCreate 940: ) { 941: if (argLeaksValue(subCmd, element)) { 942: if (statement !== null) { 943: statementsSeenInLoop.add(statement) 944: } 945: subCommandsNeedingApproval.push(subCmd) 946: continue 947: } 948: continue 949: } 950: if (subResult.behavior === 'allow') { 951: if (statement !== null) { 952: statementsSeenInLoop.add(statement) 953: } 954: subCommandsNeedingApproval.push(subCmd) 955: continue 956: } 957: if ( 958: statement !== null && 959: !hasCdSubCommand && 960: !hasSymlinkCreate && 961: isProvablySafeStatement(statement) && 962: isAllowlistedCommand(element, subCmd) 963: ) { 964: continue 965: } 966: if (statement !== null && !hasCdSubCommand && !hasSymlinkCreate) { 967: const subModeResult = checkPermissionMode( 968: { command: subCmd }, 969: { 970: valid: true, 971: errors: [], 972: variables: parsed.variables, 973: hasStopParsing: parsed.hasStopParsing, 974: originalCommand: subCmd, 975: statements: [statement], 976: }, 977: toolPermissionContext, 978: ) 979: if (subModeResult.behavior === 'allow') { 980: continue 981: } 982: } 983: if (statement !== null) { 984: statementsSeenInLoop.add(statement) 985: } 986: subCommandsNeedingApproval.push(subCmd) 987: } 988: for (const stmt of parsed.statements) { 989: if (!isProvablySafeStatement(stmt) && !statementsSeenInLoop.has(stmt)) { 990: subCommandsNeedingApproval.push(stmt.text) 991: } 992: } 993: if (subCommandsNeedingApproval.length === 0) { 994: if (deriveSecurityFlags(parsed).hasScriptBlocks) { 995: return { 996: behavior: 'ask', 997: message: createPermissionRequestMessage(POWERSHELL_TOOL_NAME), 998: decisionReason: { 999: type: 'other', 1000: reason: 1001: 'Pipeline consists of output-formatting cmdlets with script blocks — block content cannot be verified', 1002: }, 1003: } 1004: } 1005: return { 1006: behavior: 'allow', 1007: updatedInput: input, 1008: decisionReason: { 1009: type: 'other', 1010: reason: 'All pipeline commands are individually allowed', 1011: }, 1012: } 1013: } 1014: const decisionReason = { 1015: type: 'other' as const, 1016: reason: 'This command requires approval', 1017: } 1018: const pendingSuggestions: PermissionUpdate[] = [] 1019: for (const subCmd of subCommandsNeedingApproval) { 1020: pendingSuggestions.push(...suggestionForExactCommand(subCmd)) 1021: } 1022: return { 1023: behavior: 'passthrough', 1024: message: createPermissionRequestMessage( 1025: POWERSHELL_TOOL_NAME, 1026: decisionReason, 1027: ), 1028: decisionReason, 1029: suggestions: pendingSuggestions, 1030: } 1031: }

File: src/tools/PowerShellTool/powershellSecurity.ts

typescript 1: import { 2: DANGEROUS_SCRIPT_BLOCK_CMDLETS, 3: FILEPATH_EXECUTION_CMDLETS, 4: MODULE_LOADING_CMDLETS, 5: } from '../../utils/powershell/dangerousCmdlets.js' 6: import type { 7: ParsedCommandElement, 8: ParsedPowerShellCommand, 9: } from '../../utils/powershell/parser.js' 10: import { 11: COMMON_ALIASES, 12: commandHasArgAbbreviation, 13: deriveSecurityFlags, 14: getAllCommands, 15: getVariablesByScope, 16: hasCommandNamed, 17: } from '../../utils/powershell/parser.js' 18: import { isClmAllowedType } from './clmTypes.js' 19: type PowerShellSecurityResult = { 20: behavior: 'passthrough' | 'ask' | 'allow' 21: message?: string 22: } 23: const POWERSHELL_EXECUTABLES = new Set([ 24: 'pwsh', 25: 'pwsh.exe', 26: 'powershell', 27: 'powershell.exe', 28: ]) 29: function isPowerShellExecutable(name: string): boolean { 30: const lower = name.toLowerCase() 31: if (POWERSHELL_EXECUTABLES.has(lower)) { 32: return true 33: } 34: const lastSep = Math.max(lower.lastIndexOf('/'), lower.lastIndexOf('\\')) 35: if (lastSep >= 0) { 36: return POWERSHELL_EXECUTABLES.has(lower.slice(lastSep + 1)) 37: } 38: return false 39: } 40: /** 41: * Alternative parameter-prefix characters that PowerShell accepts as equivalent 42: * to ASCII hyphen-minus (U+002D). PowerShell's tokenizer (SpecialCharacters.IsDash) 43: * and powershell.exe's CommandLineParameterParser both accept all four dash 44: * characters plus Windows PowerShell 5.1's `/` parameter delimiter. 45: * Extent.Text preserves the raw character; transformCommandAst uses ce.text for 46: * CommandParameterAst elements, so these reach us unchanged. 47: */ 48: const PS_ALT_PARAM_PREFIXES = new Set([ 49: '/', 50: '\u2013', 51: '\u2014', 52: '\u2015', 53: ]) 54: function psExeHasParamAbbreviation( 55: cmd: ParsedCommandElement, 56: fullParam: string, 57: minPrefix: string, 58: ): boolean { 59: if (commandHasArgAbbreviation(cmd, fullParam, minPrefix)) { 60: return true 61: } 62: const normalized: ParsedCommandElement = { 63: ...cmd, 64: args: cmd.args.map(a => 65: a.length > 0 && PS_ALT_PARAM_PREFIXES.has(a[0]!) ? '-' + a.slice(1) : a, 66: ), 67: } 68: return commandHasArgAbbreviation(normalized, fullParam, minPrefix) 69: } 70: function checkInvokeExpression( 71: parsed: ParsedPowerShellCommand, 72: ): PowerShellSecurityResult { 73: if (hasCommandNamed(parsed, 'Invoke-Expression')) { 74: return { 75: behavior: 'ask', 76: message: 77: 'Command uses Invoke-Expression which can execute arbitrary code', 78: } 79: } 80: return { behavior: 'passthrough' } 81: } 82: function checkDynamicCommandName( 83: parsed: ParsedPowerShellCommand, 84: ): PowerShellSecurityResult { 85: for (const cmd of getAllCommands(parsed)) { 86: if (cmd.elementType !== 'CommandAst') { 87: continue 88: } 89: const nameElementType = cmd.elementTypes?.[0] 90: if (nameElementType !== undefined && nameElementType !== 'StringConstant') { 91: return { 92: behavior: 'ask', 93: message: 94: 'Command name is a dynamic expression which cannot be statically validated', 95: } 96: } 97: } 98: return { behavior: 'passthrough' } 99: } 100: function checkEncodedCommand( 101: parsed: ParsedPowerShellCommand, 102: ): PowerShellSecurityResult { 103: for (const cmd of getAllCommands(parsed)) { 104: if (isPowerShellExecutable(cmd.name)) { 105: if (psExeHasParamAbbreviation(cmd, '-encodedcommand', '-e')) { 106: return { 107: behavior: 'ask', 108: message: 'Command uses encoded parameters which obscure intent', 109: } 110: } 111: } 112: } 113: return { behavior: 'passthrough' } 114: } 115: function checkPwshCommandOrFile( 116: parsed: ParsedPowerShellCommand, 117: ): PowerShellSecurityResult { 118: for (const cmd of getAllCommands(parsed)) { 119: if (isPowerShellExecutable(cmd.name)) { 120: return { 121: behavior: 'ask', 122: message: 123: 'Command spawns a nested PowerShell process which cannot be validated', 124: } 125: } 126: } 127: return { behavior: 'passthrough' } 128: } 129: const DOWNLOADER_NAMES = new Set([ 130: 'invoke-webrequest', 131: 'iwr', 132: 'invoke-restmethod', 133: 'irm', 134: 'new-object', 135: 'start-bitstransfer', 136: ]) 137: function isDownloader(name: string): boolean { 138: return DOWNLOADER_NAMES.has(name.toLowerCase()) 139: } 140: function isIex(name: string): boolean { 141: const lower = name.toLowerCase() 142: return lower === 'invoke-expression' || lower === 'iex' 143: } 144: function checkDownloadCradles( 145: parsed: ParsedPowerShellCommand, 146: ): PowerShellSecurityResult { 147: for (const statement of parsed.statements) { 148: const cmds = statement.commands 149: if (cmds.length < 2) { 150: continue 151: } 152: const hasDownloader = cmds.some(cmd => isDownloader(cmd.name)) 153: const hasIex = cmds.some(cmd => isIex(cmd.name)) 154: if (hasDownloader && hasIex) { 155: return { 156: behavior: 'ask', 157: message: 'Command downloads and executes remote code', 158: } 159: } 160: } 161: const all = getAllCommands(parsed) 162: if (all.some(c => isDownloader(c.name)) && all.some(c => isIex(c.name))) { 163: return { 164: behavior: 'ask', 165: message: 'Command downloads and executes remote code', 166: } 167: } 168: return { behavior: 'passthrough' } 169: } 170: function checkDownloadUtilities( 171: parsed: ParsedPowerShellCommand, 172: ): PowerShellSecurityResult { 173: for (const cmd of getAllCommands(parsed)) { 174: const lower = cmd.name.toLowerCase() 175: if (lower === 'start-bitstransfer') { 176: return { 177: behavior: 'ask', 178: message: 'Command downloads files via BITS transfer', 179: } 180: } 181: if (lower === 'certutil' || lower === 'certutil.exe') { 182: const hasUrlcache = cmd.args.some(a => { 183: const la = a.toLowerCase() 184: return la === '-urlcache' || la === '/urlcache' 185: }) 186: if (hasUrlcache) { 187: return { 188: behavior: 'ask', 189: message: 'Command uses certutil to download from a URL', 190: } 191: } 192: } 193: if (lower === 'bitsadmin' || lower === 'bitsadmin.exe') { 194: if (cmd.args.some(a => a.toLowerCase() === '/transfer')) { 195: return { 196: behavior: 'ask', 197: message: 'Command downloads files via BITS transfer', 198: } 199: } 200: } 201: } 202: return { behavior: 'passthrough' } 203: } 204: function checkAddType( 205: parsed: ParsedPowerShellCommand, 206: ): PowerShellSecurityResult { 207: if (hasCommandNamed(parsed, 'Add-Type')) { 208: return { 209: behavior: 'ask', 210: message: 'Command compiles and loads .NET code', 211: } 212: } 213: return { behavior: 'passthrough' } 214: } 215: function checkComObject( 216: parsed: ParsedPowerShellCommand, 217: ): PowerShellSecurityResult { 218: for (const cmd of getAllCommands(parsed)) { 219: if (cmd.name.toLowerCase() !== 'new-object') { 220: continue 221: } 222: if (psExeHasParamAbbreviation(cmd, '-comobject', '-com')) { 223: return { 224: behavior: 'ask', 225: message: 226: 'Command instantiates a COM object which may have execution capabilities', 227: } 228: } 229: let typeName: string | undefined 230: for (let i = 0; i < cmd.args.length; i++) { 231: const a = cmd.args[i]! 232: const lower = a.toLowerCase() 233: if (lower.startsWith('-t') && lower.includes(':')) { 234: const colonIdx = a.indexOf(':') 235: const paramPart = lower.slice(0, colonIdx) 236: if ('-typename'.startsWith(paramPart)) { 237: typeName = a.slice(colonIdx + 1) 238: break 239: } 240: } 241: if ( 242: lower.startsWith('-t') && 243: '-typename'.startsWith(lower) && 244: cmd.args[i + 1] !== undefined 245: ) { 246: typeName = cmd.args[i + 1] 247: break 248: } 249: } 250: if (typeName === undefined) { 251: const VALUE_PARAMS = new Set(['-argumentlist', '-comobject', '-property']) 252: const SWITCH_PARAMS = new Set(['-strict']) 253: for (let i = 0; i < cmd.args.length; i++) { 254: const a = cmd.args[i]! 255: if (a.startsWith('-')) { 256: const lower = a.toLowerCase() 257: if (lower.startsWith('-t') && '-typename'.startsWith(lower)) { 258: i++ 259: continue 260: } 261: if (lower.includes(':')) continue 262: if (SWITCH_PARAMS.has(lower)) continue 263: if (VALUE_PARAMS.has(lower)) { 264: i++ 265: continue 266: } 267: continue 268: } 269: typeName = a 270: break 271: } 272: } 273: if (typeName !== undefined && !isClmAllowedType(typeName)) { 274: return { 275: behavior: 'ask', 276: message: `New-Object instantiates .NET type '${typeName}' outside the ConstrainedLanguage allowlist`, 277: } 278: } 279: } 280: return { behavior: 'passthrough' } 281: } 282: function checkDangerousFilePathExecution( 283: parsed: ParsedPowerShellCommand, 284: ): PowerShellSecurityResult { 285: for (const cmd of getAllCommands(parsed)) { 286: const lower = cmd.name.toLowerCase() 287: const resolved = COMMON_ALIASES[lower]?.toLowerCase() ?? lower 288: if (!FILEPATH_EXECUTION_CMDLETS.has(resolved)) { 289: continue 290: } 291: if ( 292: psExeHasParamAbbreviation(cmd, '-filepath', '-f') || 293: psExeHasParamAbbreviation(cmd, '-literalpath', '-l') 294: ) { 295: return { 296: behavior: 'ask', 297: message: `${cmd.name} -FilePath executes an arbitrary script file`, 298: } 299: } 300: for (let i = 0; i < cmd.args.length; i++) { 301: const argType = cmd.elementTypes?.[i + 1] 302: const arg = cmd.args[i] 303: if (argType === 'StringConstant' && arg && !arg.startsWith('-')) { 304: return { 305: behavior: 'ask', 306: message: `${cmd.name} with positional string argument binds to -FilePath and executes a script file`, 307: } 308: } 309: } 310: } 311: return { behavior: 'passthrough' } 312: } 313: function checkForEachMemberName( 314: parsed: ParsedPowerShellCommand, 315: ): PowerShellSecurityResult { 316: for (const cmd of getAllCommands(parsed)) { 317: const lower = cmd.name.toLowerCase() 318: const resolved = COMMON_ALIASES[lower]?.toLowerCase() ?? lower 319: if (resolved !== 'foreach-object') { 320: continue 321: } 322: if (psExeHasParamAbbreviation(cmd, '-membername', '-m')) { 323: return { 324: behavior: 'ask', 325: message: 326: 'ForEach-Object -MemberName invokes methods by string name which cannot be validated', 327: } 328: } 329: for (let i = 0; i < cmd.args.length; i++) { 330: const argType = cmd.elementTypes?.[i + 1] 331: const arg = cmd.args[i] 332: if (argType === 'StringConstant' && arg && !arg.startsWith('-')) { 333: return { 334: behavior: 'ask', 335: message: 336: 'ForEach-Object with positional string argument binds to -MemberName and invokes methods by name', 337: } 338: } 339: } 340: } 341: return { behavior: 'passthrough' } 342: } 343: function checkStartProcess( 344: parsed: ParsedPowerShellCommand, 345: ): PowerShellSecurityResult { 346: for (const cmd of getAllCommands(parsed)) { 347: const lower = cmd.name.toLowerCase() 348: if (lower !== 'start-process' && lower !== 'saps' && lower !== 'start') { 349: continue 350: } 351: if ( 352: psExeHasParamAbbreviation(cmd, '-Verb', '-v') && 353: cmd.args.some(a => a.toLowerCase() === 'runas') 354: ) { 355: return { 356: behavior: 'ask', 357: message: 'Command requests elevated privileges', 358: } 359: } 360: if (cmd.children) { 361: for (let i = 0; i < cmd.args.length; i++) { 362: const argClean = cmd.args[i]!.replace(/`/g, '') 363: if (!/^[-\u2013\u2014\u2015/]v[a-z]*:/i.test(argClean)) continue 364: const kids = cmd.children[i] 365: if (!kids) continue 366: for (const child of kids) { 367: if (child.text.replace(/['"`\s]/g, '').toLowerCase() === 'runas') { 368: return { 369: behavior: 'ask', 370: message: 'Command requests elevated privileges', 371: } 372: } 373: } 374: } 375: } 376: if ( 377: cmd.args.some(a => { 378: const clean = a.replace(/`/g, '') 379: return /^[-\u2013\u2014\u2015/]v[a-z]*:['"` ]*runas['"` ]*$/i.test( 380: clean, 381: ) 382: }) 383: ) { 384: return { 385: behavior: 'ask', 386: message: 'Command requests elevated privileges', 387: } 388: } 389: for (const arg of cmd.args) { 390: const stripped = arg.replace(/^['"]|['"]$/g, '') 391: if (isPowerShellExecutable(stripped)) { 392: return { 393: behavior: 'ask', 394: message: 395: 'Start-Process launches a nested PowerShell process which cannot be validated', 396: } 397: } 398: } 399: } 400: return { behavior: 'passthrough' } 401: } 402: /** 403: * Cmdlets where script blocks are safe (filtering/output cmdlets). 404: * Script blocks piped to these are just predicates or projections, not arbitrary execution. 405: */ 406: const SAFE_SCRIPT_BLOCK_CMDLETS = new Set([ 407: 'where-object', 408: 'sort-object', 409: 'select-object', 410: 'group-object', 411: 'format-table', 412: 'format-list', 413: 'format-wide', 414: 'format-custom', 415: // NOT foreach-object — its block is arbitrary script, not a predicate. 416: // getAllCommands recurses so commands inside the block ARE checked, but 417: // non-command AST nodes (AssignmentStatementAst etc.) are invisible to it. 418: // See powershellPermissions.ts step-5 hasScriptBlocks guard. 419: ]) 420: /** 421: * Checks for script block injection patterns where script blocks 422: * appear in suspicious contexts that could execute arbitrary code. 423: * 424: * Script blocks used with safe filtering/output cmdlets (Where-Object, 425: * Sort-Object, Select-Object, Group-Object) are allowed. 426: * Script blocks used with dangerous cmdlets (Invoke-Command, Invoke-Expression, 427: * Start-Job, etc.) are flagged. 428: */ 429: function checkScriptBlockInjection( 430: parsed: ParsedPowerShellCommand, 431: ): PowerShellSecurityResult { 432: const security = deriveSecurityFlags(parsed) 433: if (!security.hasScriptBlocks) { 434: return { behavior: 'passthrough' } 435: } 436: // Check all commands in the parsed result. If any command is in the 437: // dangerous set, flag it. If all commands with script blocks are in 438: // the safe set (or the allowlist), allow it. 439: for (const cmd of getAllCommands(parsed)) { 440: const lower = cmd.name.toLowerCase() 441: if (DANGEROUS_SCRIPT_BLOCK_CMDLETS.has(lower)) { 442: return { 443: behavior: 'ask', 444: message: 445: 'Command contains script block with dangerous cmdlet that may execute arbitrary code', 446: } 447: } 448: } 449: // Check if all commands are either safe script block consumers or don't use script blocks 450: const allCommandsSafe = getAllCommands(parsed).every(cmd => { 451: const lower = cmd.name.toLowerCase() 452: // Safe filtering/output cmdlets 453: if (SAFE_SCRIPT_BLOCK_CMDLETS.has(lower)) { 454: return true 455: } 456: // Resolve aliases 457: const alias = COMMON_ALIASES[lower] 458: if (alias && SAFE_SCRIPT_BLOCK_CMDLETS.has(alias.toLowerCase())) { 459: return true 460: } 461: // Unknown command with script blocks present — flag as potentially dangerous 462: return false 463: }) 464: if (allCommandsSafe) { 465: return { behavior: 'passthrough' } 466: } 467: return { 468: behavior: 'ask', 469: message: 'Command contains script block that may execute arbitrary code', 470: } 471: } 472: /** 473: * AST-only check: Detects subexpressions $() which can hide command execution. 474: */ 475: function checkSubExpressions( 476: parsed: ParsedPowerShellCommand, 477: ): PowerShellSecurityResult { 478: if (deriveSecurityFlags(parsed).hasSubExpressions) { 479: return { 480: behavior: 'ask', 481: message: 'Command contains subexpressions $()', 482: } 483: } 484: return { behavior: 'passthrough' } 485: } 486: /** 487: * AST-only check: Detects expandable strings (double-quoted) with embedded 488: * expressions like "$env:PATH" or "$(dangerous-command)". These can hide 489: * command execution or variable interpolation inside string literals. 490: */ 491: function checkExpandableStrings( 492: parsed: ParsedPowerShellCommand, 493: ): PowerShellSecurityResult { 494: if (deriveSecurityFlags(parsed).hasExpandableStrings) { 495: return { 496: behavior: 'ask', 497: message: 'Command contains expandable strings with embedded expressions', 498: } 499: } 500: return { behavior: 'passthrough' } 501: } 502: function checkSplatting( 503: parsed: ParsedPowerShellCommand, 504: ): PowerShellSecurityResult { 505: if (deriveSecurityFlags(parsed).hasSplatting) { 506: return { 507: behavior: 'ask', 508: message: 'Command uses splatting (@variable)', 509: } 510: } 511: return { behavior: 'passthrough' } 512: } 513: function checkStopParsing( 514: parsed: ParsedPowerShellCommand, 515: ): PowerShellSecurityResult { 516: if (deriveSecurityFlags(parsed).hasStopParsing) { 517: return { 518: behavior: 'ask', 519: message: 'Command uses stop-parsing token (--%)', 520: } 521: } 522: return { behavior: 'passthrough' } 523: } 524: function checkMemberInvocations( 525: parsed: ParsedPowerShellCommand, 526: ): PowerShellSecurityResult { 527: if (deriveSecurityFlags(parsed).hasMemberInvocations) { 528: return { 529: behavior: 'ask', 530: message: 'Command invokes .NET methods', 531: } 532: } 533: return { behavior: 'passthrough' } 534: } 535: function checkTypeLiterals( 536: parsed: ParsedPowerShellCommand, 537: ): PowerShellSecurityResult { 538: for (const t of parsed.typeLiterals ?? []) { 539: if (!isClmAllowedType(t)) { 540: return { 541: behavior: 'ask', 542: message: `Command uses .NET type [${t}] outside the ConstrainedLanguage allowlist`, 543: } 544: } 545: } 546: return { behavior: 'passthrough' } 547: } 548: function checkInvokeItem( 549: parsed: ParsedPowerShellCommand, 550: ): PowerShellSecurityResult { 551: for (const cmd of getAllCommands(parsed)) { 552: const lower = cmd.name.toLowerCase() 553: if (lower === 'invoke-item' || lower === 'ii') { 554: return { 555: behavior: 'ask', 556: message: 557: 'Invoke-Item opens files with the default handler (ShellExecute). On executable files this runs arbitrary code.', 558: } 559: } 560: } 561: return { behavior: 'passthrough' } 562: } 563: const SCHEDULED_TASK_CMDLETS = new Set([ 564: 'register-scheduledtask', 565: 'new-scheduledtask', 566: 'new-scheduledtaskaction', 567: 'set-scheduledtask', 568: ]) 569: function checkScheduledTask( 570: parsed: ParsedPowerShellCommand, 571: ): PowerShellSecurityResult { 572: for (const cmd of getAllCommands(parsed)) { 573: const lower = cmd.name.toLowerCase() 574: if (SCHEDULED_TASK_CMDLETS.has(lower)) { 575: return { 576: behavior: 'ask', 577: message: `${cmd.name} creates or modifies a scheduled task (persistence primitive)`, 578: } 579: } 580: if (lower === 'schtasks' || lower === 'schtasks.exe') { 581: if ( 582: cmd.args.some(a => { 583: const la = a.toLowerCase() 584: return ( 585: la === '/create' || 586: la === '/change' || 587: la === '-create' || 588: la === '-change' 589: ) 590: }) 591: ) { 592: return { 593: behavior: 'ask', 594: message: 595: 'schtasks with create/change modifies scheduled tasks (persistence primitive)', 596: } 597: } 598: } 599: } 600: return { behavior: 'passthrough' } 601: } 602: const ENV_WRITE_CMDLETS = new Set([ 603: 'set-item', 604: 'si', 605: 'new-item', 606: 'ni', 607: 'remove-item', 608: 'ri', 609: 'del', 610: 'rm', 611: 'rd', 612: 'rmdir', 613: 'erase', 614: 'clear-item', 615: 'cli', 616: 'set-content', 617: 'add-content', 618: 'ac', 619: ]) 620: function checkEnvVarManipulation( 621: parsed: ParsedPowerShellCommand, 622: ): PowerShellSecurityResult { 623: const envVars = getVariablesByScope(parsed, 'env') 624: if (envVars.length === 0) { 625: return { behavior: 'passthrough' } 626: } 627: for (const cmd of getAllCommands(parsed)) { 628: if (ENV_WRITE_CMDLETS.has(cmd.name.toLowerCase())) { 629: return { 630: behavior: 'ask', 631: message: 'Command modifies environment variables', 632: } 633: } 634: } 635: if (deriveSecurityFlags(parsed).hasAssignments && envVars.length > 0) { 636: return { 637: behavior: 'ask', 638: message: 'Command modifies environment variables', 639: } 640: } 641: return { behavior: 'passthrough' } 642: } 643: function checkModuleLoading( 644: parsed: ParsedPowerShellCommand, 645: ): PowerShellSecurityResult { 646: for (const cmd of getAllCommands(parsed)) { 647: const lower = cmd.name.toLowerCase() 648: if (MODULE_LOADING_CMDLETS.has(lower)) { 649: return { 650: behavior: 'ask', 651: message: 652: 'Command loads, installs, or downloads a PowerShell module or script, which can execute arbitrary code', 653: } 654: } 655: } 656: return { behavior: 'passthrough' } 657: } 658: const RUNTIME_STATE_CMDLETS = new Set([ 659: 'set-alias', 660: 'sal', 661: 'new-alias', 662: 'nal', 663: 'set-variable', 664: 'sv', 665: 'new-variable', 666: 'nv', 667: ]) 668: function checkRuntimeStateManipulation( 669: parsed: ParsedPowerShellCommand, 670: ): PowerShellSecurityResult { 671: for (const cmd of getAllCommands(parsed)) { 672: const raw = cmd.name.toLowerCase() 673: const lower = raw.includes('\\') 674: ? raw.slice(raw.lastIndexOf('\\') + 1) 675: : raw 676: if (RUNTIME_STATE_CMDLETS.has(lower)) { 677: return { 678: behavior: 'ask', 679: message: 680: 'Command creates or modifies an alias or variable that can affect future command resolution', 681: } 682: } 683: } 684: return { behavior: 'passthrough' } 685: } 686: const WMI_SPAWN_CMDLETS = new Set([ 687: 'invoke-wmimethod', 688: 'iwmi', 689: 'invoke-cimmethod', 690: ]) 691: function checkWmiProcessSpawn( 692: parsed: ParsedPowerShellCommand, 693: ): PowerShellSecurityResult { 694: for (const cmd of getAllCommands(parsed)) { 695: const lower = cmd.name.toLowerCase() 696: if (WMI_SPAWN_CMDLETS.has(lower)) { 697: return { 698: behavior: 'ask', 699: message: `${cmd.name} can spawn arbitrary processes via WMI/CIM (Win32_Process Create)`, 700: } 701: } 702: } 703: return { behavior: 'passthrough' } 704: } 705: export function powershellCommandIsSafe( 706: _command: string, 707: parsed: ParsedPowerShellCommand, 708: ): PowerShellSecurityResult { 709: if (!parsed.valid) { 710: return { 711: behavior: 'ask', 712: message: 'Could not parse command for security analysis', 713: } 714: } 715: const validators = [ 716: checkInvokeExpression, 717: checkDynamicCommandName, 718: checkEncodedCommand, 719: checkPwshCommandOrFile, 720: checkDownloadCradles, 721: checkDownloadUtilities, 722: checkAddType, 723: checkComObject, 724: checkDangerousFilePathExecution, 725: checkInvokeItem, 726: checkScheduledTask, 727: checkForEachMemberName, 728: checkStartProcess, 729: checkScriptBlockInjection, 730: checkSubExpressions, 731: checkExpandableStrings, 732: checkSplatting, 733: checkStopParsing, 734: checkMemberInvocations, 735: checkTypeLiterals, 736: checkEnvVarManipulation, 737: checkModuleLoading, 738: checkRuntimeStateManipulation, 739: checkWmiProcessSpawn, 740: ] 741: for (const validator of validators) { 742: const result = validator(parsed) 743: if (result.behavior === 'ask') { 744: return result 745: } 746: } 747: return { behavior: 'passthrough' } 748: }

File: src/tools/PowerShellTool/PowerShellTool.tsx

typescript 1: import { feature } from 'bun:bundle'; 2: import type { ToolResultBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'; 3: import { copyFile, stat as fsStat, truncate as fsTruncate, link } from 'fs/promises'; 4: import * as React from 'react'; 5: import type { CanUseToolFn } from 'src/hooks/useCanUseTool.js'; 6: import type { AppState } from 'src/state/AppState.js'; 7: import { z } from 'zod/v4'; 8: import { getKairosActive } from '../../bootstrap/state.js'; 9: import { TOOL_SUMMARY_MAX_LENGTH } from '../../constants/toolLimits.js'; 10: import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from '../../services/analytics/index.js'; 11: import type { SetToolJSXFn, Tool, ToolCallProgress, ValidationResult } from '../../Tool.js'; 12: import { buildTool, type ToolDef } from '../../Tool.js'; 13: import { backgroundExistingForegroundTask, markTaskNotified, registerForeground, spawnShellTask, unregisterForeground } from '../../tasks/LocalShellTask/LocalShellTask.js'; 14: import type { AgentId } from '../../types/ids.js'; 15: import type { AssistantMessage } from '../../types/message.js'; 16: import { extractClaudeCodeHints } from '../../utils/claudeCodeHints.js'; 17: import { isEnvTruthy } from '../../utils/envUtils.js'; 18: import { errorMessage as getErrorMessage, ShellError } from '../../utils/errors.js'; 19: import { truncate } from '../../utils/format.js'; 20: import { lazySchema } from '../../utils/lazySchema.js'; 21: import { logError } from '../../utils/log.js'; 22: import type { PermissionResult } from '../../utils/permissions/PermissionResult.js'; 23: import { getPlatform } from '../../utils/platform.js'; 24: import { maybeRecordPluginHint } from '../../utils/plugins/hintRecommendation.js'; 25: import { exec } from '../../utils/Shell.js'; 26: import type { ExecResult } from '../../utils/ShellCommand.js'; 27: import { SandboxManager } from '../../utils/sandbox/sandbox-adapter.js'; 28: import { semanticBoolean } from '../../utils/semanticBoolean.js'; 29: import { semanticNumber } from '../../utils/semanticNumber.js'; 30: import { getCachedPowerShellPath } from '../../utils/shell/powershellDetection.js'; 31: import { EndTruncatingAccumulator } from '../../utils/stringUtils.js'; 32: import { getTaskOutputPath } from '../../utils/task/diskOutput.js'; 33: import { TaskOutput } from '../../utils/task/TaskOutput.js'; 34: import { isOutputLineTruncated } from '../../utils/terminal.js'; 35: import { buildLargeToolResultMessage, ensureToolResultsDir, generatePreview, getToolResultPath, PREVIEW_SIZE_BYTES } from '../../utils/toolResultStorage.js'; 36: import { shouldUseSandbox } from '../BashTool/shouldUseSandbox.js'; 37: import { BackgroundHint } from '../BashTool/UI.js'; 38: import { buildImageToolResult, isImageOutput, resetCwdIfOutsideProject, resizeShellImageOutput, stdErrAppendShellResetMessage, stripEmptyLines } from '../BashTool/utils.js'; 39: import { trackGitOperations } from '../shared/gitOperationTracking.js'; 40: import { interpretCommandResult } from './commandSemantics.js'; 41: import { powershellToolHasPermission } from './powershellPermissions.js'; 42: import { getDefaultTimeoutMs, getMaxTimeoutMs, getPrompt } from './prompt.js'; 43: import { hasSyncSecurityConcerns, isReadOnlyCommand, resolveToCanonical } from './readOnlyValidation.js'; 44: import { POWERSHELL_TOOL_NAME } from './toolName.js'; 45: import { renderToolResultMessage, renderToolUseErrorMessage, renderToolUseMessage, renderToolUseProgressMessage, renderToolUseQueuedMessage } from './UI.js'; 46: const EOL = '\n'; 47: const PS_SEARCH_COMMANDS = new Set(['select-string', 48: 'get-childitem', 49: 'findstr', 50: 'where.exe' 51: ]); 52: const PS_READ_COMMANDS = new Set(['get-content', 53: 'get-item', 54: 'test-path', 55: 'resolve-path', 56: 'get-process', 57: 'get-service', 58: 'get-childitem', 59: 'get-location', 60: 'get-filehash', 61: 'get-acl', 62: 'format-hex' 63: ]); 64: const PS_SEMANTIC_NEUTRAL_COMMANDS = new Set(['write-output', 65: 'write-host']); 66: function isSearchOrReadPowerShellCommand(command: string): { 67: isSearch: boolean; 68: isRead: boolean; 69: } { 70: const trimmed = command.trim(); 71: if (!trimmed) { 72: return { 73: isSearch: false, 74: isRead: false 75: }; 76: } 77: const parts = trimmed.split(/\s*[;|]\s*/).filter(Boolean); 78: if (parts.length === 0) { 79: return { 80: isSearch: false, 81: isRead: false 82: }; 83: } 84: let hasSearch = false; 85: let hasRead = false; 86: let hasNonNeutralCommand = false; 87: for (const part of parts) { 88: const baseCommand = part.trim().split(/\s+/)[0]; 89: if (!baseCommand) { 90: continue; 91: } 92: const canonical = resolveToCanonical(baseCommand); 93: if (PS_SEMANTIC_NEUTRAL_COMMANDS.has(canonical)) { 94: continue; 95: } 96: hasNonNeutralCommand = true; 97: const isPartSearch = PS_SEARCH_COMMANDS.has(canonical); 98: const isPartRead = PS_READ_COMMANDS.has(canonical); 99: if (!isPartSearch && !isPartRead) { 100: return { 101: isSearch: false, 102: isRead: false 103: }; 104: } 105: if (isPartSearch) hasSearch = true; 106: if (isPartRead) hasRead = true; 107: } 108: if (!hasNonNeutralCommand) { 109: return { 110: isSearch: false, 111: isRead: false 112: }; 113: } 114: return { 115: isSearch: hasSearch, 116: isRead: hasRead 117: }; 118: } 119: const PROGRESS_THRESHOLD_MS = 2000; 120: const PROGRESS_INTERVAL_MS = 1000; 121: const ASSISTANT_BLOCKING_BUDGET_MS = 15_000; 122: const DISALLOWED_AUTO_BACKGROUND_COMMANDS = ['start-sleep', 123: 'sleep']; 124: function isAutobackgroundingAllowed(command: string): boolean { 125: const firstWord = command.trim().split(/\s+/)[0]; 126: if (!firstWord) return true; 127: const canonical = resolveToCanonical(firstWord); 128: return !DISALLOWED_AUTO_BACKGROUND_COMMANDS.includes(canonical); 129: } 130: export function detectBlockedSleepPattern(command: string): string | null { 131: const first = command.trim().split(/[;|&\r\n]/)[0]?.trim() ?? ''; 132: // Match: Start-Sleep N, Start-Sleep -Seconds N, Start-Sleep -s N, sleep N 133: // (case-insensitive; -Seconds can be abbreviated to -s per PS convention) 134: const m = /^(?:start-sleep|sleep)(?:\s+-s(?:econds)?)?\s+(\d+)\s*$/i.exec(first); 135: if (!m) return null; 136: const secs = parseInt(m[1]!, 10); 137: if (secs < 2) return null; // sub-2s sleeps are fine (rate limiting, pacing) 138: const rest = command.trim().slice(first.length).replace(/^[\s;|&]+/, ''); 139: return rest ? `Start-Sleep ${secs} followed by: ${rest}` : `standalone Start-Sleep ${secs}`; 140: } 141: /** 142: * On Windows native, sandbox is unavailable (bwrap/sandbox-exec are 143: * POSIX-only). If enterprise policy has sandbox.enabled AND forbids 144: * unsandboxed commands, PowerShell cannot comply — refuse execution 145: * rather than silently bypass the policy. On Linux/macOS/WSL2, pwsh 146: * runs as a native binary under the sandbox same as bash, so this 147: * gate does not apply. 148: * 149: * Checked in BOTH validateInput (clean tool-runner error) and call() 150: * (covers direct callers like promptShellExecution.ts that skip 151: * validateInput). The call() guard is the load-bearing one. 152: */ 153: const WINDOWS_SANDBOX_POLICY_REFUSAL = 'Enterprise policy requires sandboxing, but sandboxing is not available on native Windows. Shell command execution is blocked on this platform by policy.'; 154: function isWindowsSandboxPolicyViolation(): boolean { 155: return getPlatform() === 'windows' && SandboxManager.isSandboxEnabledInSettings() && !SandboxManager.areUnsandboxedCommandsAllowed(); 156: } 157: const isBackgroundTasksDisabled = 158: isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_BACKGROUND_TASKS); 159: const fullInputSchema = lazySchema(() => z.strictObject({ 160: command: z.string().describe('The PowerShell command to execute'), 161: timeout: semanticNumber(z.number().optional()).describe(`Optional timeout in milliseconds (max ${getMaxTimeoutMs()})`), 162: description: z.string().optional().describe('Clear, concise description of what this command does in active voice.'), 163: run_in_background: semanticBoolean(z.boolean().optional()).describe(`Set to true to run this command in the background. Use Read to read the output later.`), 164: dangerouslyDisableSandbox: semanticBoolean(z.boolean().optional()).describe('Set this to true to dangerously override sandbox mode and run commands without sandboxing.') 165: })); 166: const inputSchema = lazySchema(() => isBackgroundTasksDisabled ? fullInputSchema().omit({ 167: run_in_background: true 168: }) : fullInputSchema()); 169: type InputSchema = ReturnType<typeof inputSchema>; 170: export type PowerShellToolInput = z.infer<ReturnType<typeof fullInputSchema>>; 171: const outputSchema = lazySchema(() => z.object({ 172: stdout: z.string().describe('The standard output of the command'), 173: stderr: z.string().describe('The standard error output of the command'), 174: interrupted: z.boolean().describe('Whether the command was interrupted'), 175: returnCodeInterpretation: z.string().optional().describe('Semantic interpretation for non-error exit codes with special meaning'), 176: isImage: z.boolean().optional().describe('Flag to indicate if stdout contains image data'), 177: persistedOutputPath: z.string().optional().describe('Path to persisted full output when too large for inline'), 178: persistedOutputSize: z.number().optional().describe('Total output size in bytes when persisted'), 179: backgroundTaskId: z.string().optional().describe('ID of the background task if command is running in background'), 180: backgroundedByUser: z.boolean().optional().describe('True if the user manually backgrounded the command with Ctrl+B'), 181: assistantAutoBackgrounded: z.boolean().optional().describe('True if the command was auto-backgrounded by the assistant-mode blocking budget') 182: })); 183: type OutputSchema = ReturnType<typeof outputSchema>; 184: export type Out = z.infer<OutputSchema>; 185: import type { PowerShellProgress } from '../../types/tools.js'; 186: export type { PowerShellProgress } from '../../types/tools.js'; 187: const COMMON_BACKGROUND_COMMANDS = ['npm', 'yarn', 'pnpm', 'node', 'python', 'python3', 'go', 'cargo', 'make', 'docker', 'terraform', 'webpack', 'vite', 'jest', 'pytest', 'curl', 'Invoke-WebRequest', 'build', 'test', 'serve', 'watch', 'dev'] as const; 188: function getCommandTypeForLogging(command: string): AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS { 189: const trimmed = command.trim(); 190: const firstWord = trimmed.split(/\s+/)[0] || ''; 191: for (const cmd of COMMON_BACKGROUND_COMMANDS) { 192: if (firstWord.toLowerCase() === cmd.toLowerCase()) { 193: return cmd as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS; 194: } 195: } 196: return 'other' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS; 197: } 198: export const PowerShellTool = buildTool({ 199: name: POWERSHELL_TOOL_NAME, 200: searchHint: 'execute Windows PowerShell commands', 201: maxResultSizeChars: 30_000, 202: strict: true, 203: async description({ 204: description 205: }: Partial<PowerShellToolInput>): Promise<string> { 206: return description || 'Run PowerShell command'; 207: }, 208: async prompt(): Promise<string> { 209: return getPrompt(); 210: }, 211: isConcurrencySafe(input: PowerShellToolInput): boolean { 212: return this.isReadOnly?.(input) ?? false; 213: }, 214: isSearchOrReadCommand(input: Partial<PowerShellToolInput>): { 215: isSearch: boolean; 216: isRead: boolean; 217: } { 218: if (!input.command) { 219: return { 220: isSearch: false, 221: isRead: false 222: }; 223: } 224: return isSearchOrReadPowerShellCommand(input.command); 225: }, 226: isReadOnly(input: PowerShellToolInput): boolean { 227: if (hasSyncSecurityConcerns(input.command)) { 228: return false; 229: } 230: return isReadOnlyCommand(input.command); 231: }, 232: toAutoClassifierInput(input) { 233: return input.command; 234: }, 235: get inputSchema(): InputSchema { 236: return inputSchema(); 237: }, 238: get outputSchema(): OutputSchema { 239: return outputSchema(); 240: }, 241: userFacingName(): string { 242: return 'PowerShell'; 243: }, 244: getToolUseSummary(input: Partial<PowerShellToolInput> | undefined): string | null { 245: if (!input?.command) { 246: return null; 247: } 248: const { 249: command, 250: description 251: } = input; 252: if (description) { 253: return description; 254: } 255: return truncate(command, TOOL_SUMMARY_MAX_LENGTH); 256: }, 257: getActivityDescription(input: Partial<PowerShellToolInput> | undefined): string { 258: if (!input?.command) { 259: return 'Running command'; 260: } 261: const desc = input.description ?? truncate(input.command, TOOL_SUMMARY_MAX_LENGTH); 262: return `Running ${desc}`; 263: }, 264: isEnabled(): boolean { 265: return true; 266: }, 267: async validateInput(input: PowerShellToolInput): Promise<ValidationResult> { 268: if (isWindowsSandboxPolicyViolation()) { 269: return { 270: result: false, 271: message: WINDOWS_SANDBOX_POLICY_REFUSAL, 272: errorCode: 11 273: }; 274: } 275: if (feature('MONITOR_TOOL') && !isBackgroundTasksDisabled && !input.run_in_background) { 276: const sleepPattern = detectBlockedSleepPattern(input.command); 277: if (sleepPattern !== null) { 278: return { 279: result: false, 280: message: `Blocked: ${sleepPattern}. Run blocking commands in the background with run_in_background: true — you'll get a completion notification when done. For streaming events (watching logs, polling APIs), use the Monitor tool. If you genuinely need a delay (rate limiting, deliberate pacing), keep it under 2 seconds.`, 281: errorCode: 10 282: }; 283: } 284: } 285: return { 286: result: true 287: }; 288: }, 289: async checkPermissions(input: PowerShellToolInput, context: Parameters<Tool['checkPermissions']>[1]): Promise<PermissionResult> { 290: return await powershellToolHasPermission(input, context); 291: }, 292: renderToolUseMessage, 293: renderToolUseProgressMessage, 294: renderToolUseQueuedMessage, 295: renderToolResultMessage, 296: renderToolUseErrorMessage, 297: mapToolResultToToolResultBlockParam({ 298: interrupted, 299: stdout, 300: stderr, 301: isImage, 302: persistedOutputPath, 303: persistedOutputSize, 304: backgroundTaskId, 305: backgroundedByUser, 306: assistantAutoBackgrounded 307: }: Out, toolUseID: string): ToolResultBlockParam { 308: if (isImage) { 309: const block = buildImageToolResult(stdout, toolUseID); 310: if (block) return block; 311: } 312: let processedStdout = stdout; 313: if (persistedOutputPath) { 314: const trimmed = stdout ? stdout.replace(/^(\s*\n)+/, '').trimEnd() : ''; 315: const preview = generatePreview(trimmed, PREVIEW_SIZE_BYTES); 316: processedStdout = buildLargeToolResultMessage({ 317: filepath: persistedOutputPath, 318: originalSize: persistedOutputSize ?? 0, 319: isJson: false, 320: preview: preview.preview, 321: hasMore: preview.hasMore 322: }); 323: } else if (stdout) { 324: processedStdout = stdout.replace(/^(\s*\n)+/, ''); 325: processedStdout = processedStdout.trimEnd(); 326: } 327: let errorMessage = stderr.trim(); 328: if (interrupted) { 329: if (stderr) errorMessage += EOL; 330: errorMessage += '<error>Command was aborted before completion</error>'; 331: } 332: let backgroundInfo = ''; 333: if (backgroundTaskId) { 334: const outputPath = getTaskOutputPath(backgroundTaskId); 335: if (assistantAutoBackgrounded) { 336: backgroundInfo = `Command exceeded the assistant-mode blocking budget (${ASSISTANT_BLOCKING_BUDGET_MS / 1000}s) and was moved to the background with ID: ${backgroundTaskId}. It is still running — you will be notified when it completes. Output is being written to: ${outputPath}. In assistant mode, delegate long-running work to a subagent or use run_in_background to keep this conversation responsive.`; 337: } else if (backgroundedByUser) { 338: backgroundInfo = `Command was manually backgrounded by user with ID: ${backgroundTaskId}. Output is being written to: ${outputPath}`; 339: } else { 340: backgroundInfo = `Command running in background with ID: ${backgroundTaskId}. Output is being written to: ${outputPath}`; 341: } 342: } 343: return { 344: tool_use_id: toolUseID, 345: type: 'tool_result' as const, 346: content: [processedStdout, errorMessage, backgroundInfo].filter(Boolean).join('\n'), 347: is_error: interrupted 348: }; 349: }, 350: async call(input: PowerShellToolInput, toolUseContext: Parameters<Tool['call']>[1], _canUseTool?: CanUseToolFn, _parentMessage?: AssistantMessage, onProgress?: ToolCallProgress<PowerShellProgress>): Promise<{ 351: data: Out; 352: }> { 353: if (isWindowsSandboxPolicyViolation()) { 354: throw new Error(WINDOWS_SANDBOX_POLICY_REFUSAL); 355: } 356: const { 357: abortController, 358: setAppState, 359: setToolJSX 360: } = toolUseContext; 361: const isMainThread = !toolUseContext.agentId; 362: let progressCounter = 0; 363: try { 364: const commandGenerator = runPowerShellCommand({ 365: input, 366: abortController, 367: setAppState: toolUseContext.setAppStateForTasks ?? setAppState, 368: setToolJSX, 369: preventCwdChanges: !isMainThread, 370: isMainThread, 371: toolUseId: toolUseContext.toolUseId, 372: agentId: toolUseContext.agentId 373: }); 374: let generatorResult; 375: do { 376: generatorResult = await commandGenerator.next(); 377: if (!generatorResult.done && onProgress) { 378: const progress = generatorResult.value; 379: onProgress({ 380: toolUseID: `ps-progress-${progressCounter++}`, 381: data: { 382: type: 'powershell_progress', 383: output: progress.output, 384: fullOutput: progress.fullOutput, 385: elapsedTimeSeconds: progress.elapsedTimeSeconds, 386: totalLines: progress.totalLines, 387: totalBytes: progress.totalBytes, 388: timeoutMs: progress.timeoutMs, 389: taskId: progress.taskId 390: } 391: }); 392: } 393: } while (!generatorResult.done); 394: const result = generatorResult.value; 395: const isPreFlightSentinel = result.code === 0 && !result.stdout && result.stderr && !result.backgroundTaskId; 396: if (!isPreFlightSentinel) { 397: trackGitOperations(input.command, result.code, result.stdout); 398: } 399: const isInterrupt = result.interrupted && abortController.signal.reason === 'interrupt'; 400: let stderrForShellReset = ''; 401: if (isMainThread) { 402: const appState = toolUseContext.getAppState(); 403: if (resetCwdIfOutsideProject(appState.toolPermissionContext)) { 404: stderrForShellReset = stdErrAppendShellResetMessage(''); 405: } 406: } 407: // If backgrounded, return immediately with task ID. Strip hints first 408: // so interrupt-backgrounded fullOutput doesn't leak the tag to the 409: if (result.backgroundTaskId) { 410: const bgExtracted = extractClaudeCodeHints(result.stdout || '', input.command); 411: if (isMainThread && bgExtracted.hints.length > 0) { 412: for (const hint of bgExtracted.hints) maybeRecordPluginHint(hint); 413: } 414: return { 415: data: { 416: stdout: bgExtracted.stripped, 417: stderr: [result.stderr || '', stderrForShellReset].filter(Boolean).join('\n'), 418: interrupted: false, 419: backgroundTaskId: result.backgroundTaskId, 420: backgroundedByUser: result.backgroundedByUser, 421: assistantAutoBackgrounded: result.assistantAutoBackgrounded 422: } 423: }; 424: } 425: const stdoutAccumulator = new EndTruncatingAccumulator(); 426: const processedStdout = (result.stdout || '').trimEnd(); 427: stdoutAccumulator.append(processedStdout + EOL); 428: // Interpret exit code using semantic rules. PS-native cmdlets (Select-String, 429: // Compare-Object, Test-Path) exit 0 on no-match so they always hit the default 430: // here. This primarily handles external .exe's (grep, rg, findstr, fc, robocopy) 431: const interpretation = interpretCommandResult(input.command, result.code, processedStdout, result.stderr || ''); 432: // getErrorParts() in toolErrors.ts already prepends 'Exit code N' 433: let stdout = stripEmptyLines(stdoutAccumulator.toString()); 434: const extracted = extractClaudeCodeHints(stdout, input.command); 435: stdout = extracted.stripped; 436: if (isMainThread && extracted.hints.length > 0) { 437: for (const hint of extracted.hints) maybeRecordPluginHint(hint); 438: } 439: if (result.preSpawnError) { 440: throw new Error(result.preSpawnError); 441: } 442: if (interpretation.isError && !isInterrupt) { 443: throw new ShellError(stdout, result.stderr || '', result.code, result.interrupted); 444: } 445: // Large output: file on disk has more than getMaxOutputLength() bytes. 446: // stdout already contains the first chunk. Copy the output file to the 447: // tool-results dir so the model can read it via FileRead. If > 64 MB, 448: // truncate after copying. Matches BashTool.tsx:983-1005. 449: // 450: // Placed AFTER the preSpawnError/ShellError throws (matches BashTool's 451: const MAX_PERSISTED_SIZE = 64 * 1024 * 1024; 452: let persistedOutputPath: string | undefined; 453: let persistedOutputSize: number | undefined; 454: if (result.outputFilePath && result.outputTaskId) { 455: try { 456: const fileStat = await fsStat(result.outputFilePath); 457: persistedOutputSize = fileStat.size; 458: await ensureToolResultsDir(); 459: const dest = getToolResultPath(result.outputTaskId, false); 460: if (fileStat.size > MAX_PERSISTED_SIZE) { 461: await fsTruncate(result.outputFilePath, MAX_PERSISTED_SIZE); 462: } 463: try { 464: await link(result.outputFilePath, dest); 465: } catch { 466: await copyFile(result.outputFilePath, dest); 467: } 468: persistedOutputPath = dest; 469: } catch { 470: } 471: } 472: let isImage = isImageOutput(stdout); 473: let compressedStdout = stdout; 474: if (isImage) { 475: const resized = await resizeShellImageOutput(stdout, result.outputFilePath, persistedOutputSize); 476: if (resized) { 477: compressedStdout = resized; 478: } else { 479: isImage = false; 480: } 481: } 482: const finalStderr = [result.stderr || '', stderrForShellReset].filter(Boolean).join('\n'); 483: logEvent('tengu_powershell_tool_command_executed', { 484: command_type: getCommandTypeForLogging(input.command), 485: stdout_length: compressedStdout.length, 486: stderr_length: finalStderr.length, 487: exit_code: result.code, 488: interrupted: result.interrupted 489: }); 490: return { 491: data: { 492: stdout: compressedStdout, 493: stderr: finalStderr, 494: interrupted: result.interrupted, 495: returnCodeInterpretation: interpretation.message, 496: isImage, 497: persistedOutputPath, 498: persistedOutputSize 499: } 500: }; 501: } finally { 502: if (setToolJSX) setToolJSX(null); 503: } 504: }, 505: isResultTruncated(output: Out): boolean { 506: return isOutputLineTruncated(output.stdout) || isOutputLineTruncated(output.stderr); 507: } 508: } satisfies ToolDef<InputSchema, Out>); 509: async function* runPowerShellCommand({ 510: input, 511: abortController, 512: setAppState, 513: setToolJSX, 514: preventCwdChanges, 515: isMainThread, 516: toolUseId, 517: agentId 518: }: { 519: input: PowerShellToolInput; 520: abortController: AbortController; 521: setAppState: (f: (prev: AppState) => AppState) => void; 522: setToolJSX?: SetToolJSXFn; 523: preventCwdChanges?: boolean; 524: isMainThread?: boolean; 525: toolUseId?: string; 526: agentId?: AgentId; 527: }): AsyncGenerator<{ 528: type: 'progress'; 529: output: string; 530: fullOutput: string; 531: elapsedTimeSeconds: number; 532: totalLines: number; 533: totalBytes: number; 534: taskId?: string; 535: timeoutMs?: number; 536: }, ExecResult, void> { 537: const { 538: command, 539: description, 540: timeout, 541: run_in_background, 542: dangerouslyDisableSandbox 543: } = input; 544: const timeoutMs = Math.min(timeout || getDefaultTimeoutMs(), getMaxTimeoutMs()); 545: let fullOutput = ''; 546: let lastProgressOutput = ''; 547: let lastTotalLines = 0; 548: let lastTotalBytes = 0; 549: let backgroundShellId: string | undefined = undefined; 550: let interruptBackgroundingStarted = false; 551: let assistantAutoBackgrounded = false; 552: // Progress signal: resolved when backgroundShellId is set in the async 553: // .then() path, waking the generator's Promise.race immediately instead of 554: let resolveProgress: (() => void) | null = null; 555: function createProgressSignal(): Promise<null> { 556: return new Promise<null>(resolve => { 557: resolveProgress = () => resolve(null); 558: }); 559: } 560: const shouldAutoBackground = !isBackgroundTasksDisabled && isAutobackgroundingAllowed(command); 561: const powershellPath = await getCachedPowerShellPath(); 562: if (!powershellPath) { 563: return { 564: stdout: '', 565: stderr: 'PowerShell is not available on this system.', 566: code: 0, 567: interrupted: false 568: }; 569: } 570: let shellCommand: Awaited<ReturnType<typeof exec>>; 571: try { 572: shellCommand = await exec(command, abortController.signal, 'powershell', { 573: timeout: timeoutMs, 574: onProgress(lastLines, allLines, totalLines, totalBytes, isIncomplete) { 575: lastProgressOutput = lastLines; 576: fullOutput = allLines; 577: lastTotalLines = totalLines; 578: lastTotalBytes = isIncomplete ? totalBytes : 0; 579: }, 580: preventCwdChanges, 581: shouldUseSandbox: getPlatform() === 'windows' ? false : shouldUseSandbox({ 582: command, 583: dangerouslyDisableSandbox 584: }), 585: shouldAutoBackground 586: }); 587: } catch (e) { 588: logError(e); 589: return { 590: stdout: '', 591: stderr: `Failed to execute PowerShell command: ${getErrorMessage(e)}`, 592: code: 0, 593: interrupted: false 594: }; 595: } 596: const resultPromise = shellCommand.result; 597: // Helper to spawn a background task and return its ID 598: async function spawnBackgroundTask(): Promise<string> { 599: const handle = await spawnShellTask({ 600: command, 601: description: description || command, 602: shellCommand, 603: toolUseId, 604: agentId 605: }, { 606: abortController, 607: getAppState: () => { 608: throw new Error('getAppState not available in runPowerShellCommand context'); 609: }, 610: setAppState 611: }); 612: return handle.taskId; 613: } 614: function startBackgrounding(eventName: string, backgroundFn?: (shellId: string) => void): void { 615: if (foregroundTaskId) { 616: if (!backgroundExistingForegroundTask(foregroundTaskId, shellCommand, description || command, setAppState, toolUseId)) { 617: return; 618: } 619: backgroundShellId = foregroundTaskId; 620: logEvent(eventName, { 621: command_type: getCommandTypeForLogging(command) 622: }); 623: backgroundFn?.(foregroundTaskId); 624: return; 625: } 626: void spawnBackgroundTask().then(shellId => { 627: backgroundShellId = shellId; 628: const resolve = resolveProgress; 629: if (resolve) { 630: resolveProgress = null; 631: resolve(); 632: } 633: logEvent(eventName, { 634: command_type: getCommandTypeForLogging(command) 635: }); 636: if (backgroundFn) { 637: backgroundFn(shellId); 638: } 639: }); 640: } 641: if (shellCommand.onTimeout && shouldAutoBackground) { 642: shellCommand.onTimeout(backgroundFn => { 643: startBackgrounding('tengu_powershell_command_timeout_backgrounded', backgroundFn); 644: }); 645: } 646: if (feature('KAIROS') && getKairosActive() && isMainThread && !isBackgroundTasksDisabled && run_in_background !== true) { 647: setTimeout(() => { 648: if (shellCommand.status === 'running' && backgroundShellId === undefined) { 649: assistantAutoBackgrounded = true; 650: startBackgrounding('tengu_powershell_command_assistant_auto_backgrounded'); 651: } 652: }, ASSISTANT_BLOCKING_BUDGET_MS).unref(); 653: } 654: if (run_in_background === true && !isBackgroundTasksDisabled) { 655: const shellId = await spawnBackgroundTask(); 656: logEvent('tengu_powershell_command_explicitly_backgrounded', { 657: command_type: getCommandTypeForLogging(command) 658: }); 659: return { 660: stdout: '', 661: stderr: '', 662: code: 0, 663: interrupted: false, 664: backgroundTaskId: shellId 665: }; 666: } 667: // Start polling the output file for progress 668: TaskOutput.startPolling(shellCommand.taskOutput.taskId); 669: // Set up progress yielding with periodic checks 670: const startTime = Date.now(); 671: let nextProgressTime = startTime + PROGRESS_THRESHOLD_MS; 672: let foregroundTaskId: string | undefined = undefined; 673: // Progress loop: wrap in try/finally so stopPolling is called on every exit 674: // path — normal completion, timeout/interrupt backgrounding, and Ctrl+B 675: // (matches BashTool pattern; see PR #18887 review thread at :560) 676: try { 677: while (true) { 678: const now = Date.now(); 679: const timeUntilNextProgress = Math.max(0, nextProgressTime - now); 680: const progressSignal = createProgressSignal(); 681: const result = await Promise.race([resultPromise, new Promise<null>(resolve => setTimeout(r => r(null), timeUntilNextProgress, resolve).unref()), progressSignal]); 682: if (result !== null) { 683: // Race: backgrounding fired (15s timer / onTimeout / Ctrl+B) but the 684: // command completed before the next poll tick. #handleExit sets 685: // backgroundTaskId but skips outputFilePath (it assumes the background 686: // message or <task_notification> will carry the path). Strip 687: // backgroundTaskId so the model sees a clean completed command, 688: // reconstruct outputFilePath for large outputs, and suppress the 689: // redundant <task_notification> from the .then() handler. 690: // Check result.backgroundTaskId (not the closure var) to also cover 691: // Ctrl+B, which calls shellCommand.background() directly. 692: if (result.backgroundTaskId !== undefined) { 693: markTaskNotified(result.backgroundTaskId, setAppState); 694: const fixedResult: ExecResult = { 695: ...result, 696: backgroundTaskId: undefined 697: }; 698: // Mirror ShellCommand.#handleExit's large-output branch that was 699: const { 700: taskOutput 701: } = shellCommand; 702: if (taskOutput.stdoutToFile && !taskOutput.outputFileRedundant) { 703: fixedResult.outputFilePath = taskOutput.path; 704: fixedResult.outputFileSize = taskOutput.outputFileSize; 705: fixedResult.outputTaskId = taskOutput.taskId; 706: } 707: shellCommand.cleanup(); 708: return fixedResult; 709: } 710: return result; 711: } 712: if (backgroundShellId) { 713: return { 714: stdout: interruptBackgroundingStarted ? fullOutput : '', 715: stderr: '', 716: code: 0, 717: interrupted: false, 718: backgroundTaskId: backgroundShellId, 719: assistantAutoBackgrounded 720: }; 721: } 722: // User submitted a new message - background instead of killing 723: if (abortController.signal.aborted && abortController.signal.reason === 'interrupt' && !interruptBackgroundingStarted) { 724: interruptBackgroundingStarted = true; 725: if (!isBackgroundTasksDisabled) { 726: startBackgrounding('tengu_powershell_command_interrupt_backgrounded'); 727: continue; 728: } 729: shellCommand.kill(); 730: } 731: if (foregroundTaskId) { 732: if (shellCommand.status === 'backgrounded') { 733: return { 734: stdout: '', 735: stderr: '', 736: code: 0, 737: interrupted: false, 738: backgroundTaskId: foregroundTaskId, 739: backgroundedByUser: true 740: }; 741: } 742: } 743: // Time for a progress update 744: const elapsed = Date.now() - startTime; 745: const elapsedSeconds = Math.floor(elapsed / 1000); 746: // Show backgrounding UI hint after threshold 747: if (!isBackgroundTasksDisabled && backgroundShellId === undefined && elapsedSeconds >= PROGRESS_THRESHOLD_MS / 1000 && setToolJSX) { 748: if (!foregroundTaskId) { 749: foregroundTaskId = registerForeground({ 750: command, 751: description: description || command, 752: shellCommand, 753: agentId 754: }, setAppState, toolUseId); 755: } 756: setToolJSX({ 757: jsx: <BackgroundHint />, 758: shouldHidePromptInput: false, 759: shouldContinueAnimation: true, 760: showSpinner: true 761: }); 762: } 763: yield { 764: type: 'progress', 765: fullOutput, 766: output: lastProgressOutput, 767: elapsedTimeSeconds: elapsedSeconds, 768: totalLines: lastTotalLines, 769: totalBytes: lastTotalBytes, 770: taskId: shellCommand.taskOutput.taskId, 771: ...(timeout ? { 772: timeoutMs 773: } : undefined) 774: }; 775: nextProgressTime = Date.now() + PROGRESS_INTERVAL_MS; 776: } 777: } finally { 778: TaskOutput.stopPolling(shellCommand.taskOutput.taskId); 779: if (!backgroundShellId && shellCommand.status !== 'backgrounded') { 780: if (foregroundTaskId) { 781: unregisterForeground(foregroundTaskId, setAppState); 782: } 783: shellCommand.cleanup(); 784: } 785: } 786: }

File: src/tools/PowerShellTool/prompt.ts

typescript 1: import { isEnvTruthy } from '../../utils/envUtils.js' 2: import { getMaxOutputLength } from '../../utils/shell/outputLimits.js' 3: import { 4: getPowerShellEdition, 5: type PowerShellEdition, 6: } from '../../utils/shell/powershellDetection.js' 7: import { 8: getDefaultBashTimeoutMs, 9: getMaxBashTimeoutMs, 10: } from '../../utils/timeouts.js' 11: import { FILE_EDIT_TOOL_NAME } from '../FileEditTool/constants.js' 12: import { FILE_READ_TOOL_NAME } from '../FileReadTool/prompt.js' 13: import { FILE_WRITE_TOOL_NAME } from '../FileWriteTool/prompt.js' 14: import { GLOB_TOOL_NAME } from '../GlobTool/prompt.js' 15: import { GREP_TOOL_NAME } from '../GrepTool/prompt.js' 16: import { POWERSHELL_TOOL_NAME } from './toolName.js' 17: export function getDefaultTimeoutMs(): number { 18: return getDefaultBashTimeoutMs() 19: } 20: export function getMaxTimeoutMs(): number { 21: return getMaxBashTimeoutMs() 22: } 23: function getBackgroundUsageNote(): string | null { 24: if (isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_BACKGROUND_TASKS)) { 25: return null 26: } 27: return ` - You can use the \`run_in_background\` parameter to run the command in the background. Only use this if you don't need the result immediately and are OK being notified when the command completes later. You do not need to check the output right away - you'll be notified when it finishes.` 28: } 29: function getSleepGuidance(): string | null { 30: if (isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_BACKGROUND_TASKS)) { 31: return null 32: } 33: return ` - Avoid unnecessary \`Start-Sleep\` commands: 34: - Do not sleep between commands that can run immediately — just run them. 35: - If your command is long running and you would like to be notified when it finishes — simply run your command using \`run_in_background\`. There is no need to sleep in this case. 36: - Do not retry failing commands in a sleep loop — diagnose the root cause or consider an alternative approach. 37: - If waiting for a background task you started with \`run_in_background\`, you will be notified when it completes — do not poll. 38: - If you must poll an external process, use a check command rather than sleeping first. 39: - If you must sleep, keep the duration short (1-5 seconds) to avoid blocking the user.` 40: } 41: function getEditionSection(edition: PowerShellEdition | null): string { 42: if (edition === 'desktop') { 43: return `PowerShell edition: Windows PowerShell 5.1 (powershell.exe) 44: - Pipeline chain operators \`&&\` and \`||\` are NOT available — they cause a parser error. To run B only if A succeeds: \`A; if ($?) { B }\`. To chain unconditionally: \`A; B\`. 45: - Ternary (\`?:\`), null-coalescing (\`??\`), and null-conditional (\`?.\`) operators are NOT available. Use \`if/else\` and explicit \`$null -eq\` checks instead. 46: - Avoid \`2>&1\` on native executables. In 5.1, redirecting a native command's stderr inside PowerShell wraps each line in an ErrorRecord (NativeCommandError) and sets \`$?\` to \`$false\` even when the exe returned exit code 0. stderr is already captured for you — don't redirect it. 47: - Default file encoding is UTF-16 LE (with BOM). When writing files other tools will read, pass \`-Encoding utf8\` to \`Out-File\`/\`Set-Content\`. 48: - \`ConvertFrom-Json\` returns a PSCustomObject, not a hashtable. \`-AsHashtable\` is not available.` 49: } 50: if (edition === 'core') { 51: return `PowerShell edition: PowerShell 7+ (pwsh) 52: - Pipeline chain operators \`&&\` and \`||\` ARE available and work like bash. Prefer \`cmd1 && cmd2\` over \`cmd1; cmd2\` when cmd2 should only run if cmd1 succeeds. 53: - Ternary (\`$cond ? $a : $b\`), null-coalescing (\`??\`), and null-conditional (\`?.\`) operators are available. 54: - Default file encoding is UTF-8 without BOM.` 55: } 56: return `PowerShell edition: unknown — assume Windows PowerShell 5.1 for compatibility 57: - Do NOT use \`&&\`, \`||\`, ternary \`?:\`, null-coalescing \`??\`, or null-conditional \`?.\`. These are PowerShell 7+ only and parser-error on 5.1. 58: - To chain commands conditionally: \`A; if ($?) { B }\`. Unconditionally: \`A; B\`.` 59: } 60: export async function getPrompt(): Promise<string> { 61: const backgroundNote = getBackgroundUsageNote() 62: const sleepGuidance = getSleepGuidance() 63: const edition = await getPowerShellEdition() 64: return `Executes a given PowerShell command with optional timeout. Working directory persists between commands; shell state (variables, functions) does not. 65: IMPORTANT: This tool is for terminal operations via PowerShell: git, npm, docker, and PS cmdlets. DO NOT use it for file operations (reading, writing, editing, searching, finding files) - use the specialized tools for this instead. 66: ${getEditionSection(edition)} 67: Before executing the command, please follow these steps: 68: 1. Directory Verification: 69: - If the command will create new directories or files, first use \`Get-ChildItem\` (or \`ls\`) to verify the parent directory exists and is the correct location 70: 2. Command Execution: 71: - Always quote file paths that contain spaces with double quotes 72: - Capture the output of the command. 73: PowerShell Syntax Notes: 74: - Variables use $ prefix: $myVar = "value" 75: - Escape character is backtick (\`), not backslash 76: - Use Verb-Noun cmdlet naming: Get-ChildItem, Set-Location, New-Item, Remove-Item 77: - Common aliases: ls (Get-ChildItem), cd (Set-Location), cat (Get-Content), rm (Remove-Item) 78: - Pipe operator | works similarly to bash but passes objects, not text 79: - Use Select-Object, Where-Object, ForEach-Object for filtering and transformation 80: - String interpolation: "Hello $name" or "Hello $($obj.Property)" 81: - Registry access uses PSDrive prefixes: \`HKLM:\\SOFTWARE\\...\`, \`HKCU:\\...\` — NOT raw \`HKEY_LOCAL_MACHINE\\...\` 82: - Environment variables: read with \`$env:NAME\`, set with \`$env:NAME = "value"\` (NOT \`Set-Variable\` or bash \`export\`) 83: - Call native exe with spaces in path via call operator: \`& "C:\\Program Files\\App\\app.exe" arg1 arg2\` 84: Interactive and blocking commands (will hang — this tool runs with -NonInteractive): 85: - NEVER use \`Read-Host\`, \`Get-Credential\`, \`Out-GridView\`, \`$Host.UI.PromptForChoice\`, or \`pause\` 86: - Destructive cmdlets (\`Remove-Item\`, \`Stop-Process\`, \`Clear-Content\`, etc.) may prompt for confirmation. Add \`-Confirm:$false\` when you intend the action to proceed. Use \`-Force\` for read-only/hidden items. 87: - Never use \`git rebase -i\`, \`git add -i\`, or other commands that open an interactive editor 88: Passing multiline strings (commit messages, file content) to native executables: 89: - Use a single-quoted here-string so PowerShell does not expand \`$\` or backticks inside. The closing \`'@\` MUST be at column 0 (no leading whitespace) on its own line — indenting it is a parse error: 90: <example> 91: git commit -m @' 92: Commit message here. 93: Second line with $literal dollar signs. 94: '@ 95: </example> 96: - Use \`@'...'@\` (single-quoted, literal) not \`@"..."@\` (double-quoted, interpolated) unless you need variable expansion 97: - For arguments containing \`-\`, \`@\`, or other characters PowerShell parses as operators, use the stop-parsing token: \`git log --% --format=%H\` 98: Usage notes: 99: - The command argument is required. 100: - You can specify an optional timeout in milliseconds (up to ${getMaxTimeoutMs()}ms / ${getMaxTimeoutMs() / 60000} minutes). If not specified, commands will timeout after ${getDefaultTimeoutMs()}ms (${getDefaultTimeoutMs() / 60000} minutes). 101: - It is very helpful if you write a clear, concise description of what this command does. 102: - If the output exceeds ${getMaxOutputLength()} characters, output will be truncated before being returned to you. 103: ${backgroundNote ? backgroundNote + '\n' : ''}\ 104: - Avoid using PowerShell to run commands that have dedicated tools, unless explicitly instructed: 105: - File search: Use ${GLOB_TOOL_NAME} (NOT Get-ChildItem -Recurse) 106: - Content search: Use ${GREP_TOOL_NAME} (NOT Select-String) 107: - Read files: Use ${FILE_READ_TOOL_NAME} (NOT Get-Content) 108: - Edit files: Use ${FILE_EDIT_TOOL_NAME} 109: - Write files: Use ${FILE_WRITE_TOOL_NAME} (NOT Set-Content/Out-File) 110: - Communication: Output text directly (NOT Write-Output/Write-Host) 111: - When issuing multiple commands: 112: - If the commands are independent and can run in parallel, make multiple ${POWERSHELL_TOOL_NAME} tool calls in a single message. 113: - If the commands depend on each other and must run sequentially, chain them in a single ${POWERSHELL_TOOL_NAME} call (see edition-specific chaining syntax above). 114: - Use \`;\` only when you need to run commands sequentially but don't care if earlier commands fail. 115: - DO NOT use newlines to separate commands (newlines are ok in quoted strings and here-strings) 116: - Do NOT prefix commands with \`cd\` or \`Set-Location\` -- the working directory is already set to the correct project directory automatically. 117: ${sleepGuidance ? sleepGuidance + '\n' : ''}\ 118: - For git commands: 119: - Prefer to create a new commit rather than amending an existing commit. 120: - Before running destructive operations (e.g., git reset --hard, git push --force, git checkout --), consider whether there is a safer alternative that achieves the same goal. Only use destructive operations when they are truly the best approach. 121: - Never skip hooks (--no-verify) or bypass signing (--no-gpg-sign, -c commit.gpgsign=false) unless the user has explicitly asked for it. If a hook fails, investigate and fix the underlying issue.` 122: }

File: src/tools/PowerShellTool/readOnlyValidation.ts

typescript 1: import type { 2: ParsedCommandElement, 3: ParsedPowerShellCommand, 4: } from '../../utils/powershell/parser.js' 5: type ParsedStatement = ParsedPowerShellCommand['statements'][number] 6: import { getPlatform } from '../../utils/platform.js' 7: import { 8: COMMON_ALIASES, 9: deriveSecurityFlags, 10: getPipelineSegments, 11: isNullRedirectionTarget, 12: isPowerShellParameter, 13: } from '../../utils/powershell/parser.js' 14: import type { ExternalCommandConfig } from '../../utils/shell/readOnlyCommandValidation.js' 15: import { 16: DOCKER_READ_ONLY_COMMANDS, 17: EXTERNAL_READONLY_COMMANDS, 18: GH_READ_ONLY_COMMANDS, 19: GIT_READ_ONLY_COMMANDS, 20: validateFlags, 21: } from '../../utils/shell/readOnlyCommandValidation.js' 22: import { COMMON_PARAMETERS } from './commonParameters.js' 23: const DOTNET_READ_ONLY_FLAGS = new Set([ 24: '--version', 25: '--info', 26: '--list-runtimes', 27: '--list-sdks', 28: ]) 29: type CommandConfig = { 30: safeFlags?: string[] 31: allowAllFlags?: boolean 32: regex?: RegExp 33: additionalCommandIsDangerousCallback?: ( 34: command: string, 35: element?: ParsedCommandElement, 36: ) => boolean 37: } 38: export function argLeaksValue( 39: _cmd: string, 40: element?: ParsedCommandElement, 41: ): boolean { 42: const argTypes = (element?.elementTypes ?? []).slice(1) 43: const args = element?.args ?? [] 44: const children = element?.children 45: for (let i = 0; i < argTypes.length; i++) { 46: if (argTypes[i] !== 'StringConstant' && argTypes[i] !== 'Parameter') { 47: if (!/[$(@{[]/.test(args[i] ?? '')) { 48: continue 49: } 50: return true 51: } 52: if (argTypes[i] === 'Parameter') { 53: const paramChildren = children?.[i] 54: if (paramChildren) { 55: if (paramChildren.some(c => c.type !== 'StringConstant')) { 56: return true 57: } 58: } else { 59: const arg = args[i] ?? '' 60: const colonIdx = arg.indexOf(':') 61: if (colonIdx > 0 && /[$(@{[]/.test(arg.slice(colonIdx + 1))) { 62: return true 63: } 64: } 65: } 66: } 67: return false 68: } 69: /** 70: * Allowlist of PowerShell cmdlets that are considered read-only. 71: * Each cmdlet maps to its configuration including safe flags. 72: * 73: * Note: PowerShell cmdlets are case-insensitive, so we store keys in lowercase 74: * and normalize input for matching. 75: * 76: * Uses Object.create(null) to prevent prototype-chain pollution — attacker- 77: * controlled command names like 'constructor' or '__proto__' must return 78: * undefined, not inherited Object.prototype properties. Same defense as 79: * COMMON_ALIASES in parser.ts. 80: */ 81: export const CMDLET_ALLOWLIST: Record<string, CommandConfig> = Object.assign( 82: Object.create(null) as Record<string, CommandConfig>, 83: { 84: 'get-childitem': { 85: safeFlags: [ 86: '-Path', 87: '-LiteralPath', 88: '-Filter', 89: '-Include', 90: '-Exclude', 91: '-Recurse', 92: '-Depth', 93: '-Name', 94: '-Force', 95: '-Attributes', 96: '-Directory', 97: '-File', 98: '-Hidden', 99: '-ReadOnly', 100: '-System', 101: ], 102: }, 103: 'get-content': { 104: safeFlags: [ 105: '-Path', 106: '-LiteralPath', 107: '-TotalCount', 108: '-Head', 109: '-Tail', 110: '-Raw', 111: '-Encoding', 112: '-Delimiter', 113: '-ReadCount', 114: ], 115: }, 116: 'get-item': { 117: safeFlags: ['-Path', '-LiteralPath', '-Force', '-Stream'], 118: }, 119: 'get-itemproperty': { 120: safeFlags: ['-Path', '-LiteralPath', '-Name'], 121: }, 122: 'test-path': { 123: safeFlags: [ 124: '-Path', 125: '-LiteralPath', 126: '-PathType', 127: '-Filter', 128: '-Include', 129: '-Exclude', 130: '-IsValid', 131: '-NewerThan', 132: '-OlderThan', 133: ], 134: }, 135: 'resolve-path': { 136: safeFlags: ['-Path', '-LiteralPath', '-Relative'], 137: }, 138: 'get-filehash': { 139: safeFlags: ['-Path', '-LiteralPath', '-Algorithm', '-InputStream'], 140: }, 141: 'get-acl': { 142: safeFlags: [ 143: '-Path', 144: '-LiteralPath', 145: '-Audit', 146: '-Filter', 147: '-Include', 148: '-Exclude', 149: ], 150: }, 151: 'set-location': { 152: safeFlags: ['-Path', '-LiteralPath', '-PassThru', '-StackName'], 153: }, 154: 'push-location': { 155: safeFlags: ['-Path', '-LiteralPath', '-PassThru', '-StackName'], 156: }, 157: 'pop-location': { 158: safeFlags: ['-PassThru', '-StackName'], 159: }, 160: 'select-string': { 161: safeFlags: [ 162: '-Path', 163: '-LiteralPath', 164: '-Pattern', 165: '-InputObject', 166: '-SimpleMatch', 167: '-CaseSensitive', 168: '-Quiet', 169: '-List', 170: '-NotMatch', 171: '-AllMatches', 172: '-Encoding', 173: '-Context', 174: '-Raw', 175: '-NoEmphasis', 176: ], 177: }, 178: 'convertto-json': { 179: safeFlags: [ 180: '-InputObject', 181: '-Depth', 182: '-Compress', 183: '-EnumsAsStrings', 184: '-AsArray', 185: ], 186: }, 187: 'convertfrom-json': { 188: safeFlags: ['-InputObject', '-Depth', '-AsHashtable', '-NoEnumerate'], 189: }, 190: 'convertto-csv': { 191: safeFlags: [ 192: '-InputObject', 193: '-Delimiter', 194: '-NoTypeInformation', 195: '-NoHeader', 196: '-UseQuotes', 197: ], 198: }, 199: 'convertfrom-csv': { 200: safeFlags: ['-InputObject', '-Delimiter', '-Header', '-UseCulture'], 201: }, 202: 'convertto-xml': { 203: safeFlags: ['-InputObject', '-Depth', '-As', '-NoTypeInformation'], 204: }, 205: 'convertto-html': { 206: safeFlags: [ 207: '-InputObject', 208: '-Property', 209: '-Head', 210: '-Title', 211: '-Body', 212: '-Pre', 213: '-Post', 214: '-As', 215: '-Fragment', 216: ], 217: }, 218: 'format-hex': { 219: safeFlags: [ 220: '-Path', 221: '-LiteralPath', 222: '-InputObject', 223: '-Encoding', 224: '-Count', 225: '-Offset', 226: ], 227: }, 228: 'get-member': { 229: safeFlags: [ 230: '-InputObject', 231: '-MemberType', 232: '-Name', 233: '-Static', 234: '-View', 235: '-Force', 236: ], 237: }, 238: 'get-unique': { 239: safeFlags: ['-InputObject', '-AsString', '-CaseInsensitive', '-OnType'], 240: }, 241: 'compare-object': { 242: safeFlags: [ 243: '-ReferenceObject', 244: '-DifferenceObject', 245: '-Property', 246: '-SyncWindow', 247: '-CaseSensitive', 248: '-Culture', 249: '-ExcludeDifferent', 250: '-IncludeEqual', 251: '-PassThru', 252: ], 253: }, 254: 'join-string': { 255: safeFlags: [ 256: '-InputObject', 257: '-Property', 258: '-Separator', 259: '-OutputPrefix', 260: '-OutputSuffix', 261: '-SingleQuote', 262: '-DoubleQuote', 263: '-FormatString', 264: ], 265: }, 266: 'get-random': { 267: safeFlags: [ 268: '-InputObject', 269: '-Minimum', 270: '-Maximum', 271: '-Count', 272: '-SetSeed', 273: '-Shuffle', 274: ], 275: }, 276: 'convert-path': { 277: safeFlags: ['-Path', '-LiteralPath'], 278: }, 279: 'join-path': { 280: safeFlags: ['-Path', '-ChildPath', '-AdditionalChildPath'], 281: }, 282: 'split-path': { 283: safeFlags: [ 284: '-Path', 285: '-LiteralPath', 286: '-Qualifier', 287: '-NoQualifier', 288: '-Parent', 289: '-Leaf', 290: '-LeafBase', 291: '-Extension', 292: '-IsAbsolute', 293: ], 294: }, 295: 'get-hotfix': { 296: safeFlags: ['-Id', '-Description'], 297: }, 298: 'get-itempropertyvalue': { 299: safeFlags: ['-Path', '-LiteralPath', '-Name'], 300: }, 301: 'get-psprovider': { 302: safeFlags: ['-PSProvider'], 303: }, 304: 'get-process': { 305: safeFlags: [ 306: '-Name', 307: '-Id', 308: '-Module', 309: '-FileVersionInfo', 310: '-IncludeUserName', 311: ], 312: }, 313: 'get-service': { 314: safeFlags: [ 315: '-Name', 316: '-DisplayName', 317: '-DependentServices', 318: '-RequiredServices', 319: '-Include', 320: '-Exclude', 321: ], 322: }, 323: 'get-computerinfo': { 324: allowAllFlags: true, 325: }, 326: 'get-host': { 327: allowAllFlags: true, 328: }, 329: 'get-date': { 330: safeFlags: ['-Date', '-Format', '-UFormat', '-DisplayHint', '-AsUTC'], 331: }, 332: 'get-location': { 333: safeFlags: ['-PSProvider', '-PSDrive', '-Stack', '-StackName'], 334: }, 335: 'get-psdrive': { 336: safeFlags: ['-Name', '-PSProvider', '-Scope'], 337: }, 338: 'get-module': { 339: safeFlags: [ 340: '-Name', 341: '-ListAvailable', 342: '-All', 343: '-FullyQualifiedName', 344: '-PSEdition', 345: ], 346: }, 347: 'get-alias': { 348: safeFlags: ['-Name', '-Definition', '-Scope', '-Exclude'], 349: }, 350: 'get-history': { 351: safeFlags: ['-Id', '-Count'], 352: }, 353: 'get-culture': { 354: allowAllFlags: true, 355: }, 356: 'get-uiculture': { 357: allowAllFlags: true, 358: }, 359: 'get-timezone': { 360: safeFlags: ['-Name', '-Id', '-ListAvailable'], 361: }, 362: 'get-uptime': { 363: allowAllFlags: true, 364: }, 365: 'write-output': { 366: safeFlags: ['-InputObject', '-NoEnumerate'], 367: additionalCommandIsDangerousCallback: argLeaksValue, 368: }, 369: 'write-host': { 370: safeFlags: [ 371: '-Object', 372: '-NoNewline', 373: '-Separator', 374: '-ForegroundColor', 375: '-BackgroundColor', 376: ], 377: additionalCommandIsDangerousCallback: argLeaksValue, 378: }, 379: 'start-sleep': { 380: safeFlags: ['-Seconds', '-Milliseconds', '-Duration'], 381: additionalCommandIsDangerousCallback: argLeaksValue, 382: }, 383: 'format-table': { 384: allowAllFlags: true, 385: additionalCommandIsDangerousCallback: argLeaksValue, 386: }, 387: 'format-list': { 388: allowAllFlags: true, 389: additionalCommandIsDangerousCallback: argLeaksValue, 390: }, 391: 'format-wide': { 392: allowAllFlags: true, 393: additionalCommandIsDangerousCallback: argLeaksValue, 394: }, 395: 'format-custom': { 396: allowAllFlags: true, 397: additionalCommandIsDangerousCallback: argLeaksValue, 398: }, 399: 'measure-object': { 400: allowAllFlags: true, 401: additionalCommandIsDangerousCallback: argLeaksValue, 402: }, 403: 'select-object': { 404: allowAllFlags: true, 405: additionalCommandIsDangerousCallback: argLeaksValue, 406: }, 407: 'sort-object': { 408: allowAllFlags: true, 409: additionalCommandIsDangerousCallback: argLeaksValue, 410: }, 411: 'group-object': { 412: allowAllFlags: true, 413: additionalCommandIsDangerousCallback: argLeaksValue, 414: }, 415: 'where-object': { 416: allowAllFlags: true, 417: additionalCommandIsDangerousCallback: argLeaksValue, 418: }, 419: 'out-string': { 420: allowAllFlags: true, 421: additionalCommandIsDangerousCallback: argLeaksValue, 422: }, 423: 'out-host': { 424: allowAllFlags: true, 425: additionalCommandIsDangerousCallback: argLeaksValue, 426: }, 427: 'get-netadapter': { 428: safeFlags: [ 429: '-Name', 430: '-InterfaceDescription', 431: '-InterfaceIndex', 432: '-Physical', 433: ], 434: }, 435: 'get-netipaddress': { 436: safeFlags: [ 437: '-InterfaceIndex', 438: '-InterfaceAlias', 439: '-AddressFamily', 440: '-Type', 441: ], 442: }, 443: 'get-netipconfiguration': { 444: safeFlags: ['-InterfaceIndex', '-InterfaceAlias', '-Detailed', '-All'], 445: }, 446: 'get-netroute': { 447: safeFlags: [ 448: '-InterfaceIndex', 449: '-InterfaceAlias', 450: '-AddressFamily', 451: '-DestinationPrefix', 452: ], 453: }, 454: 'get-dnsclientcache': { 455: safeFlags: ['-Entry', '-Name', '-Type', '-Status', '-Section', '-Data'], 456: }, 457: 'get-dnsclient': { 458: safeFlags: ['-InterfaceIndex', '-InterfaceAlias'], 459: }, 460: 'get-eventlog': { 461: safeFlags: [ 462: '-LogName', 463: '-Newest', 464: '-After', 465: '-Before', 466: '-EntryType', 467: '-Index', 468: '-InstanceId', 469: '-Message', 470: '-Source', 471: '-UserName', 472: '-AsBaseObject', 473: '-List', 474: ], 475: }, 476: 'get-winevent': { 477: safeFlags: [ 478: '-LogName', 479: '-ListLog', 480: '-ListProvider', 481: '-ProviderName', 482: '-Path', 483: '-MaxEvents', 484: '-FilterXPath', 485: '-Force', 486: '-Oldest', 487: ], 488: }, 489: 'get-cimclass': { 490: safeFlags: [ 491: '-ClassName', 492: '-Namespace', 493: '-MethodName', 494: '-PropertyName', 495: '-QualifierName', 496: ], 497: }, 498: git: {}, 499: gh: {}, 500: docker: {}, 501: ipconfig: { 502: safeFlags: ['/all', '/displaydns', '/allcompartments'], 503: additionalCommandIsDangerousCallback: ( 504: _cmd: string, 505: element?: ParsedCommandElement, 506: ) => { 507: return (element?.args ?? []).some( 508: a => !a.startsWith('/') && !a.startsWith('-'), 509: ) 510: }, 511: }, 512: netstat: { 513: safeFlags: [ 514: '-a', 515: '-b', 516: '-e', 517: '-f', 518: '-n', 519: '-o', 520: '-p', 521: '-q', 522: '-r', 523: '-s', 524: '-t', 525: '-x', 526: '-y', 527: ], 528: }, 529: systeminfo: { 530: safeFlags: ['/FO', '/NH'], 531: }, 532: tasklist: { 533: safeFlags: ['/M', '/SVC', '/V', '/FI', '/FO', '/NH'], 534: }, 535: 'where.exe': { 536: allowAllFlags: true, 537: }, 538: hostname: { 539: safeFlags: ['-a', '-d', '-f', '-i', '-I', '-s', '-y', '-A'], 540: additionalCommandIsDangerousCallback: ( 541: _cmd: string, 542: element?: ParsedCommandElement, 543: ) => { 544: return (element?.args ?? []).some(a => !a.startsWith('-')) 545: }, 546: }, 547: whoami: { 548: safeFlags: [ 549: '/user', 550: '/groups', 551: '/claims', 552: '/priv', 553: '/logonid', 554: '/all', 555: '/fo', 556: '/nh', 557: ], 558: }, 559: ver: { 560: allowAllFlags: true, 561: }, 562: arp: { 563: safeFlags: ['-a', '-g', '-v', '-N'], 564: }, 565: route: { 566: safeFlags: ['print', 'PRINT', '-4', '-6'], 567: additionalCommandIsDangerousCallback: ( 568: _cmd: string, 569: element?: ParsedCommandElement, 570: ) => { 571: if (!element) { 572: return true 573: } 574: const verb = element.args.find(a => !a.startsWith('-')) 575: return verb?.toLowerCase() !== 'print' 576: }, 577: }, 578: getmac: { 579: safeFlags: ['/FO', '/NH', '/V'], 580: }, 581: file: { 582: safeFlags: [ 583: '-b', 584: '--brief', 585: '-i', 586: '--mime', 587: '-L', 588: '--dereference', 589: '--mime-type', 590: '--mime-encoding', 591: '-z', 592: '--uncompress', 593: '-p', 594: '--preserve-date', 595: '-k', 596: '--keep-going', 597: '-r', 598: '--raw', 599: '-v', 600: '--version', 601: '-0', 602: '--print0', 603: '-s', 604: '--special-files', 605: '-l', 606: '-F', 607: '--separator', 608: '-e', 609: '-P', 610: '-N', 611: '--no-pad', 612: '-E', 613: '--extension', 614: ], 615: }, 616: tree: { 617: safeFlags: ['/F', '/A', '/Q', '/L'], 618: }, 619: findstr: { 620: safeFlags: [ 621: '/B', 622: '/E', 623: '/L', 624: '/R', 625: '/S', 626: '/I', 627: '/X', 628: '/V', 629: '/N', 630: '/M', 631: '/O', 632: '/P', 633: '/C', 634: '/G', 635: '/D', 636: '/A', 637: ], 638: }, 639: dotnet: {}, 640: }, 641: ) 642: const SAFE_OUTPUT_CMDLETS = new Set([ 643: 'out-null', 644: ]) 645: const PIPELINE_TAIL_CMDLETS = new Set([ 646: 'format-table', 647: 'format-list', 648: 'format-wide', 649: 'format-custom', 650: 'measure-object', 651: 'select-object', 652: 'sort-object', 653: 'group-object', 654: 'where-object', 655: 'out-string', 656: 'out-host', 657: ]) 658: const SAFE_EXTERNAL_EXES = new Set(['where.exe']) 659: const WINDOWS_PATHEXT = /\.(exe|cmd|bat|com)$/ 660: export function resolveToCanonical(name: string): string { 661: let lower = name.toLowerCase() 662: if (!lower.includes('\\') && !lower.includes('/')) { 663: lower = lower.replace(WINDOWS_PATHEXT, '') 664: } 665: const alias = COMMON_ALIASES[lower] 666: if (alias) { 667: return alias.toLowerCase() 668: } 669: return lower 670: } 671: /** 672: * Checks if a command name (after alias resolution) alters the path-resolution 673: * namespace for subsequent statements in the same compound command. 674: * 675: * Covers TWO classes: 676: * 1. Cwd-changing cmdlets: Set-Location, Push-Location, Pop-Location (and 677: * aliases cd, sl, chdir, pushd, popd). Subsequent relative paths resolve 678: * from the new cwd. 679: * 2. PSDrive-creating cmdlets: New-PSDrive (and aliases ndr, mount on Windows). 680: * Subsequent drive-prefixed paths (p:/foo) resolve via the new drive root, 681: * not via the filesystem. Finding #21: `New-PSDrive -Name p -Root /etc; 682: * Remove-Item p:/passwd` — the validator cannot know p: maps to /etc. 683: * 684: * Any compound containing one of these cannot have its later statements' 685: * relative/drive-prefixed paths validated against the stale validator cwd. 686: * 687: * Name kept for BashTool parity (isCwdChangingCmdlet ↔ compoundCommandHasCd); 688: * semantically this is "alters path-resolution namespace". 689: */ 690: export function isCwdChangingCmdlet(name: string): boolean { 691: const canonical = resolveToCanonical(name) 692: return ( 693: canonical === 'set-location' || 694: canonical === 'push-location' || 695: canonical === 'pop-location' || 696: canonical === 'new-psdrive' || 697: (getPlatform() === 'windows' && 698: (canonical === 'ndr' || canonical === 'mount')) 699: ) 700: } 701: export function isSafeOutputCommand(name: string): boolean { 702: const canonical = resolveToCanonical(name) 703: return SAFE_OUTPUT_CMDLETS.has(canonical) 704: } 705: export function isAllowlistedPipelineTail( 706: cmd: ParsedCommandElement, 707: originalCommand: string, 708: ): boolean { 709: const canonical = resolveToCanonical(cmd.name) 710: if (!PIPELINE_TAIL_CMDLETS.has(canonical)) { 711: return false 712: } 713: return isAllowlistedCommand(cmd, originalCommand) 714: } 715: export function isProvablySafeStatement(stmt: ParsedStatement): boolean { 716: if (stmt.statementType !== 'PipelineAst') return false 717: if (stmt.commands.length === 0) return false 718: for (const cmd of stmt.commands) { 719: if (cmd.elementType !== 'CommandAst') return false 720: } 721: return true 722: } 723: function lookupAllowlist(name: string): CommandConfig | undefined { 724: const lower = name.toLowerCase() 725: const direct = CMDLET_ALLOWLIST[lower] 726: if (direct) { 727: return direct 728: } 729: const canonical = resolveToCanonical(lower) 730: if (canonical !== lower) { 731: return CMDLET_ALLOWLIST[canonical] 732: } 733: return undefined 734: } 735: export function hasSyncSecurityConcerns(command: string): boolean { 736: const trimmed = command.trim() 737: if (!trimmed) { 738: return false 739: } 740: if (/\$\(/.test(trimmed)) { 741: return true 742: } 743: if (/(?:^|[^\w.])@\w+/.test(trimmed)) { 744: return true 745: } 746: if (/\.\w+\s*\(/.test(trimmed)) { 747: return true 748: } 749: if (/\$\w+\s*[+\-*/]?=/.test(trimmed)) { 750: return true 751: } 752: if (/--%/.test(trimmed)) { 753: return true 754: } 755: if (/\\\\/.test(trimmed) || /(?<!:)\/\//.test(trimmed)) { 756: return true 757: } 758: if (/::/.test(trimmed)) { 759: return true 760: } 761: return false 762: } 763: export function isReadOnlyCommand( 764: command: string, 765: parsed?: ParsedPowerShellCommand, 766: ): boolean { 767: const trimmedCommand = command.trim() 768: if (!trimmedCommand) { 769: return false 770: } 771: if (!parsed) { 772: return false 773: } 774: if (!parsed.valid) { 775: return false 776: } 777: const security = deriveSecurityFlags(parsed) 778: if ( 779: security.hasScriptBlocks || 780: security.hasSubExpressions || 781: security.hasExpandableStrings || 782: security.hasSplatting || 783: security.hasMemberInvocations || 784: security.hasAssignments || 785: security.hasStopParsing 786: ) { 787: return false 788: } 789: const segments = getPipelineSegments(parsed) 790: if (segments.length === 0) { 791: return false 792: } 793: const totalCommands = segments.reduce( 794: (sum, seg) => sum + seg.commands.length, 795: 0, 796: ) 797: if (totalCommands > 1) { 798: const hasCd = segments.some(seg => 799: seg.commands.some(cmd => isCwdChangingCmdlet(cmd.name)), 800: ) 801: if (hasCd) { 802: return false 803: } 804: } 805: for (const pipeline of segments) { 806: if (!pipeline || pipeline.commands.length === 0) { 807: return false 808: } 809: if (pipeline.redirections.length > 0) { 810: const hasFileRedirection = pipeline.redirections.some( 811: r => !r.isMerging && !isNullRedirectionTarget(r.target), 812: ) 813: if (hasFileRedirection) { 814: return false 815: } 816: } 817: const firstCmd = pipeline.commands[0] 818: if (!firstCmd) { 819: return false 820: } 821: if (!isAllowlistedCommand(firstCmd, command)) { 822: return false 823: } 824: for (let i = 1; i < pipeline.commands.length; i++) { 825: const cmd = pipeline.commands[i] 826: if (!cmd || cmd.nameType === 'application') { 827: return false 828: } 829: if (isSafeOutputCommand(cmd.name) && cmd.args.length === 0) { 830: continue 831: } 832: if (!isAllowlistedCommand(cmd, command)) { 833: return false 834: } 835: } 836: if (pipeline.nestedCommands && pipeline.nestedCommands.length > 0) { 837: return false 838: } 839: } 840: return true 841: } 842: export function isAllowlistedCommand( 843: cmd: ParsedCommandElement, 844: originalCommand: string, 845: ): boolean { 846: if (cmd.nameType === 'application') { 847: const rawFirstToken = cmd.text.split(/\s/, 1)[0]?.toLowerCase() ?? '' 848: if (!SAFE_EXTERNAL_EXES.has(rawFirstToken)) { 849: return false 850: } 851: // Fall through to lookupAllowlist — CMDLET_ALLOWLIST['where.exe'] handles 852: } 853: const config = lookupAllowlist(cmd.name) 854: if (!config) { 855: return false 856: } 857: if (config.regex && !config.regex.test(originalCommand)) { 858: return false 859: } 860: if (config.additionalCommandIsDangerousCallback?.(originalCommand, cmd)) { 861: return false 862: } 863: if (!cmd.elementTypes) { 864: return false 865: } 866: { 867: for (let i = 1; i < cmd.elementTypes.length; i++) { 868: const t = cmd.elementTypes[i] 869: if (t !== 'StringConstant' && t !== 'Parameter') { 870: if (!/[$(@{[]/.test(cmd.args[i - 1] ?? '')) { 871: continue 872: } 873: return false 874: } 875: // Colon-bound parameter (`-Flag:$env:SECRET`) is a SINGLE 876: // CommandParameterAst — the VariableExpressionAst is its .Argument 877: // child, not a separate CommandElement, so elementTypes says 'Parameter' 878: if (t === 'Parameter') { 879: const paramChildren = cmd.children?.[i - 1] 880: if (paramChildren) { 881: if (paramChildren.some(c => c.type !== 'StringConstant')) { 882: return false 883: } 884: } else { 885: const arg = cmd.args[i - 1] ?? '' 886: const colonIdx = arg.indexOf(':') 887: if (colonIdx > 0 && /[$(@{[]/.test(arg.slice(colonIdx + 1))) { 888: return false 889: } 890: } 891: } 892: } 893: } 894: const canonical = resolveToCanonical(cmd.name) 895: // Handle external commands via shared validation 896: if ( 897: canonical === 'git' || 898: canonical === 'gh' || 899: canonical === 'docker' || 900: canonical === 'dotnet' 901: ) { 902: return isExternalCommandSafe(canonical, cmd.args) 903: } 904: const isCmdlet = canonical.includes('-') 905: if (config.allowAllFlags) { 906: return true 907: } 908: if (!config.safeFlags || config.safeFlags.length === 0) { 909: const hasFlags = cmd.args.some((arg, i) => { 910: if (isCmdlet) { 911: return isPowerShellParameter(arg, cmd.elementTypes?.[i + 1]) 912: } 913: return ( 914: arg.startsWith('-') || 915: (process.platform === 'win32' && arg.startsWith('/')) 916: ) 917: }) 918: return !hasFlags 919: } 920: for (let i = 0; i < cmd.args.length; i++) { 921: const arg = cmd.args[i]! 922: const isFlag = isCmdlet 923: ? isPowerShellParameter(arg, cmd.elementTypes?.[i + 1]) 924: : arg.startsWith('-') || 925: (process.platform === 'win32' && arg.startsWith('/')) 926: if (isFlag) { 927: let paramName = isCmdlet ? '-' + arg.slice(1) : arg 928: const colonIndex = paramName.indexOf(':') 929: if (colonIndex > 0) { 930: paramName = paramName.substring(0, colonIndex) 931: } 932: const paramLower = paramName.toLowerCase() 933: if (isCmdlet && COMMON_PARAMETERS.has(paramLower)) { 934: continue 935: } 936: const isSafe = config.safeFlags.some( 937: flag => flag.toLowerCase() === paramLower, 938: ) 939: if (!isSafe) { 940: return false 941: } 942: } 943: } 944: return true 945: } 946: function isExternalCommandSafe(command: string, args: string[]): boolean { 947: switch (command) { 948: case 'git': 949: return isGitSafe(args) 950: case 'gh': 951: return isGhSafe(args) 952: case 'docker': 953: return isDockerSafe(args) 954: case 'dotnet': 955: return isDotnetSafe(args) 956: default: 957: return false 958: } 959: } 960: const DANGEROUS_GIT_GLOBAL_FLAGS = new Set([ 961: '-c', 962: '-C', 963: '--exec-path', 964: '--config-env', 965: '--git-dir', 966: '--work-tree', 967: '--attr-source', 968: ]) 969: const GIT_GLOBAL_FLAGS_WITH_VALUES = new Set([ 970: '-c', 971: '-C', 972: '--exec-path', 973: '--config-env', 974: '--git-dir', 975: '--work-tree', 976: '--namespace', 977: '--super-prefix', 978: '--shallow-file', 979: ]) 980: const DANGEROUS_GIT_SHORT_FLAGS_ATTACHED = ['-c', '-C'] 981: function isGitSafe(args: string[]): boolean { 982: if (args.length === 0) { 983: return true 984: } 985: for (const arg of args) { 986: if (arg.includes('$')) { 987: return false 988: } 989: } 990: let idx = 0 991: while (idx < args.length) { 992: const arg = args[idx] 993: if (!arg || !arg.startsWith('-')) { 994: break 995: } 996: for (const shortFlag of DANGEROUS_GIT_SHORT_FLAGS_ATTACHED) { 997: if ( 998: arg.length > shortFlag.length && 999: arg.startsWith(shortFlag) && 1000: (shortFlag === '-C' || arg[shortFlag.length] !== '-') 1001: ) { 1002: return false 1003: } 1004: } 1005: const hasInlineValue = arg.includes('=') 1006: const flagName = hasInlineValue ? arg.split('=')[0] || '' : arg 1007: if (DANGEROUS_GIT_GLOBAL_FLAGS.has(flagName)) { 1008: return false 1009: } 1010: // Consume the next token if the flag takes a separate value 1011: if (!hasInlineValue && GIT_GLOBAL_FLAGS_WITH_VALUES.has(flagName)) { 1012: idx += 2 1013: } else { 1014: idx++ 1015: } 1016: } 1017: if (idx >= args.length) { 1018: return true 1019: } 1020: // Try multi-word subcommand first (e.g. 'stash list', 'config --get', 'remote show') 1021: const first = args[idx]?.toLowerCase() || '' 1022: const second = idx + 1 < args.length ? args[idx + 1]?.toLowerCase() || '' : '' 1023: // GIT_READ_ONLY_COMMANDS keys are like 'git diff', 'git stash list' 1024: const twoWordKey = `git ${first} ${second}` 1025: const oneWordKey = `git ${first}` 1026: let config: ExternalCommandConfig | undefined = 1027: GIT_READ_ONLY_COMMANDS[twoWordKey] 1028: let subcommandTokens = 2 1029: if (!config) { 1030: config = GIT_READ_ONLY_COMMANDS[oneWordKey] 1031: subcommandTokens = 1 1032: } 1033: if (!config) { 1034: return false 1035: } 1036: const flagArgs = args.slice(idx + subcommandTokens) 1037: if (first === 'ls-remote') { 1038: for (const arg of flagArgs) { 1039: if (!arg.startsWith('-')) { 1040: if ( 1041: arg.includes('://') || 1042: arg.includes('@') || 1043: arg.includes(':') || 1044: arg.includes('$') 1045: ) { 1046: return false 1047: } 1048: } 1049: } 1050: } 1051: if ( 1052: config.additionalCommandIsDangerousCallback && 1053: config.additionalCommandIsDangerousCallback('', flagArgs) 1054: ) { 1055: return false 1056: } 1057: return validateFlags(flagArgs, 0, config, { commandName: 'git' }) 1058: } 1059: function isGhSafe(args: string[]): boolean { 1060: if (process.env.USER_TYPE !== 'ant') { 1061: return false 1062: } 1063: if (args.length === 0) { 1064: return true 1065: } 1066: let config: ExternalCommandConfig | undefined 1067: let subcommandTokens = 0 1068: if (args.length >= 2) { 1069: const twoWordKey = `gh ${args[0]?.toLowerCase()} ${args[1]?.toLowerCase()}` 1070: config = GH_READ_ONLY_COMMANDS[twoWordKey] 1071: subcommandTokens = 2 1072: } 1073: if (!config && args.length >= 1) { 1074: const oneWordKey = `gh ${args[0]?.toLowerCase()}` 1075: config = GH_READ_ONLY_COMMANDS[oneWordKey] 1076: subcommandTokens = 1 1077: } 1078: if (!config) { 1079: return false 1080: } 1081: const flagArgs = args.slice(subcommandTokens) 1082: for (const arg of flagArgs) { 1083: if (arg.includes('$')) { 1084: return false 1085: } 1086: } 1087: if ( 1088: config.additionalCommandIsDangerousCallback && 1089: config.additionalCommandIsDangerousCallback('', flagArgs) 1090: ) { 1091: return false 1092: } 1093: return validateFlags(flagArgs, 0, config) 1094: } 1095: function isDockerSafe(args: string[]): boolean { 1096: if (args.length === 0) { 1097: return true 1098: } 1099: // SECURITY: blanket PowerShell `$` variable rejection. Same guard as 1100: // isGitSafe and isGhSafe. Parser differential: validator sees literal 1101: // '$env:X'; PowerShell expands at runtime. Runs BEFORE the fast-path 1102: for (const arg of args) { 1103: if (arg.includes('$')) { 1104: return false 1105: } 1106: } 1107: const oneWordKey = `docker ${args[0]?.toLowerCase()}` 1108: if (EXTERNAL_READONLY_COMMANDS.includes(oneWordKey)) { 1109: return true 1110: } 1111: const config: ExternalCommandConfig | undefined = 1112: DOCKER_READ_ONLY_COMMANDS[oneWordKey] 1113: if (!config) { 1114: return false 1115: } 1116: const flagArgs = args.slice(1) 1117: if ( 1118: config.additionalCommandIsDangerousCallback && 1119: config.additionalCommandIsDangerousCallback('', flagArgs) 1120: ) { 1121: return false 1122: } 1123: return validateFlags(flagArgs, 0, config) 1124: } 1125: function isDotnetSafe(args: string[]): boolean { 1126: if (args.length === 0) { 1127: return false 1128: } 1129: for (const arg of args) { 1130: if (!DOTNET_READ_ONLY_FLAGS.has(arg.toLowerCase())) { 1131: return false 1132: } 1133: } 1134: return true 1135: }

File: src/tools/PowerShellTool/toolName.ts

typescript 1: export const POWERSHELL_TOOL_NAME = 'PowerShell' as const

File: src/tools/PowerShellTool/UI.tsx

typescript 1: import type { ToolResultBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'; 2: import * as React from 'react'; 3: import { KeyboardShortcutHint } from '../../components/design-system/KeyboardShortcutHint.js'; 4: import { FallbackToolUseErrorMessage } from '../../components/FallbackToolUseErrorMessage.js'; 5: import { MessageResponse } from '../../components/MessageResponse.js'; 6: import { OutputLine } from '../../components/shell/OutputLine.js'; 7: import { ShellProgressMessage } from '../../components/shell/ShellProgressMessage.js'; 8: import { ShellTimeDisplay } from '../../components/shell/ShellTimeDisplay.js'; 9: import { Box, Text } from '../../ink.js'; 10: import type { Tool } from '../../Tool.js'; 11: import type { ProgressMessage } from '../../types/message.js'; 12: import type { PowerShellProgress } from '../../types/tools.js'; 13: import type { ThemeName } from '../../utils/theme.js'; 14: import type { Out, PowerShellToolInput } from './PowerShellTool.js'; 15: const MAX_COMMAND_DISPLAY_LINES = 2; 16: const MAX_COMMAND_DISPLAY_CHARS = 160; 17: export function renderToolUseMessage(input: Partial<PowerShellToolInput>, { 18: verbose, 19: theme: _theme 20: }: { 21: verbose: boolean; 22: theme: ThemeName; 23: }): React.ReactNode { 24: const { 25: command 26: } = input; 27: if (!command) { 28: return null; 29: } 30: const displayCommand = command; 31: if (!verbose) { 32: const lines = displayCommand.split('\n'); 33: const needsLineTruncation = lines.length > MAX_COMMAND_DISPLAY_LINES; 34: const needsCharTruncation = displayCommand.length > MAX_COMMAND_DISPLAY_CHARS; 35: if (needsLineTruncation || needsCharTruncation) { 36: let truncated = displayCommand; 37: if (needsLineTruncation) { 38: truncated = lines.slice(0, MAX_COMMAND_DISPLAY_LINES).join('\n'); 39: } 40: if (truncated.length > MAX_COMMAND_DISPLAY_CHARS) { 41: truncated = truncated.slice(0, MAX_COMMAND_DISPLAY_CHARS); 42: } 43: return <Text>{truncated.trim()}…</Text>; 44: } 45: } 46: return displayCommand; 47: } 48: export function renderToolUseProgressMessage(progressMessagesForMessage: ProgressMessage<PowerShellProgress>[], { 49: verbose, 50: tools: _tools, 51: terminalSize: _terminalSize, 52: inProgressToolCallCount: _inProgressToolCallCount 53: }: { 54: tools: Tool[]; 55: verbose: boolean; 56: terminalSize?: { 57: columns: number; 58: rows: number; 59: }; 60: inProgressToolCallCount?: number; 61: }): React.ReactNode { 62: const lastProgress = progressMessagesForMessage.at(-1); 63: if (!lastProgress || !lastProgress.data) { 64: return <MessageResponse height={1}> 65: <Text dimColor>Running…</Text> 66: </MessageResponse>; 67: } 68: const data = lastProgress.data; 69: return <ShellProgressMessage fullOutput={data.fullOutput} output={data.output} elapsedTimeSeconds={data.elapsedTimeSeconds} totalLines={data.totalLines} totalBytes={data.totalBytes} timeoutMs={data.timeoutMs} taskId={data.taskId} verbose={verbose} />; 70: } 71: export function renderToolUseQueuedMessage(): React.ReactNode { 72: return <MessageResponse height={1}> 73: <Text dimColor>Waiting…</Text> 74: </MessageResponse>; 75: } 76: export function renderToolResultMessage(content: Out, progressMessagesForMessage: ProgressMessage<PowerShellProgress>[], { 77: verbose, 78: theme: _theme, 79: tools: _tools, 80: style: _style 81: }: { 82: verbose: boolean; 83: theme: ThemeName; 84: tools: Tool[]; 85: style?: 'condensed'; 86: }): React.ReactNode { 87: const lastProgress = progressMessagesForMessage.at(-1); 88: const timeoutMs = lastProgress?.data?.timeoutMs; 89: const { 90: stdout, 91: stderr, 92: interrupted, 93: returnCodeInterpretation, 94: isImage, 95: backgroundTaskId 96: } = content; 97: if (isImage) { 98: return <MessageResponse height={1}> 99: <Text dimColor>[Image data detected and sent to Claude]</Text> 100: </MessageResponse>; 101: } 102: return <Box flexDirection="column"> 103: {stdout !== '' ? <OutputLine content={stdout} verbose={verbose} /> : null} 104: {stderr.trim() !== '' ? <OutputLine content={stderr} verbose={verbose} isError /> : null} 105: {stdout === '' && stderr.trim() === '' ? <MessageResponse height={1}> 106: <Text dimColor> 107: {backgroundTaskId ? <> 108: Running in the background{' '} 109: <KeyboardShortcutHint shortcut="↓" action="manage" parens /> 110: </> : interrupted ? 'Interrupted' : returnCodeInterpretation || '(No output)'} 111: </Text> 112: </MessageResponse> : null} 113: {timeoutMs ? <MessageResponse> 114: <ShellTimeDisplay timeoutMs={timeoutMs} /> 115: </MessageResponse> : null} 116: </Box>; 117: } 118: export function renderToolUseErrorMessage(result: ToolResultBlockParam['content'], { 119: verbose, 120: progressMessagesForMessage: _progressMessagesForMessage, 121: tools: _tools 122: }: { 123: verbose: boolean; 124: progressMessagesForMessage: ProgressMessage<PowerShellProgress>[]; 125: tools: Tool[]; 126: }): React.ReactNode { 127: return <FallbackToolUseErrorMessage result={result} verbose={verbose} />; 128: }

File: src/tools/ReadMcpResourceTool/prompt.ts

typescript 1: export const DESCRIPTION = ` 2: Reads a specific resource from an MCP server. 3: - server: The name of the MCP server to read from 4: - uri: The URI of the resource to read 5: Usage examples: 6: - Read a resource from a server: \`readMcpResource({ server: "myserver", uri: "my-resource-uri" })\` 7: ` 8: export const PROMPT = ` 9: Reads a specific resource from an MCP server, identified by server name and resource URI. 10: Parameters: 11: - server (required): The name of the MCP server from which to read the resource 12: - uri (required): The URI of the resource to read 13: `

File: src/tools/ReadMcpResourceTool/ReadMcpResourceTool.ts

typescript 1: import { 2: type ReadResourceResult, 3: ReadResourceResultSchema, 4: } from '@modelcontextprotocol/sdk/types.js' 5: import { z } from 'zod/v4' 6: import { ensureConnectedClient } from '../../services/mcp/client.js' 7: import { buildTool, type ToolDef } from '../../Tool.js' 8: import { lazySchema } from '../../utils/lazySchema.js' 9: import { 10: getBinaryBlobSavedMessage, 11: persistBinaryContent, 12: } from '../../utils/mcpOutputStorage.js' 13: import { jsonStringify } from '../../utils/slowOperations.js' 14: import { isOutputLineTruncated } from '../../utils/terminal.js' 15: import { DESCRIPTION, PROMPT } from './prompt.js' 16: import { 17: renderToolResultMessage, 18: renderToolUseMessage, 19: userFacingName, 20: } from './UI.js' 21: export const inputSchema = lazySchema(() => 22: z.object({ 23: server: z.string().describe('The MCP server name'), 24: uri: z.string().describe('The resource URI to read'), 25: }), 26: ) 27: type InputSchema = ReturnType<typeof inputSchema> 28: export const outputSchema = lazySchema(() => 29: z.object({ 30: contents: z.array( 31: z.object({ 32: uri: z.string().describe('Resource URI'), 33: mimeType: z.string().optional().describe('MIME type of the content'), 34: text: z.string().optional().describe('Text content of the resource'), 35: blobSavedTo: z 36: .string() 37: .optional() 38: .describe('Path where binary blob content was saved'), 39: }), 40: ), 41: }), 42: ) 43: type OutputSchema = ReturnType<typeof outputSchema> 44: export type Output = z.infer<OutputSchema> 45: export const ReadMcpResourceTool = buildTool({ 46: isConcurrencySafe() { 47: return true 48: }, 49: isReadOnly() { 50: return true 51: }, 52: toAutoClassifierInput(input) { 53: return `${input.server} ${input.uri}` 54: }, 55: shouldDefer: true, 56: name: 'ReadMcpResourceTool', 57: searchHint: 'read a specific MCP resource by URI', 58: maxResultSizeChars: 100_000, 59: async description() { 60: return DESCRIPTION 61: }, 62: async prompt() { 63: return PROMPT 64: }, 65: get inputSchema(): InputSchema { 66: return inputSchema() 67: }, 68: get outputSchema(): OutputSchema { 69: return outputSchema() 70: }, 71: async call(input, { options: { mcpClients } }) { 72: const { server: serverName, uri } = input 73: const client = mcpClients.find(client => client.name === serverName) 74: if (!client) { 75: throw new Error( 76: `Server "${serverName}" not found. Available servers: ${mcpClients.map(c => c.name).join(', ')}`, 77: ) 78: } 79: if (client.type !== 'connected') { 80: throw new Error(`Server "${serverName}" is not connected`) 81: } 82: if (!client.capabilities?.resources) { 83: throw new Error(`Server "${serverName}" does not support resources`) 84: } 85: const connectedClient = await ensureConnectedClient(client) 86: const result = (await connectedClient.client.request( 87: { 88: method: 'resources/read', 89: params: { uri }, 90: }, 91: ReadResourceResultSchema, 92: )) as ReadResourceResult 93: const contents = await Promise.all( 94: result.contents.map(async (c, i) => { 95: if ('text' in c) { 96: return { uri: c.uri, mimeType: c.mimeType, text: c.text } 97: } 98: if (!('blob' in c) || typeof c.blob !== 'string') { 99: return { uri: c.uri, mimeType: c.mimeType } 100: } 101: const persistId = `mcp-resource-${Date.now()}-${i}-${Math.random().toString(36).slice(2, 8)}` 102: const persisted = await persistBinaryContent( 103: Buffer.from(c.blob, 'base64'), 104: c.mimeType, 105: persistId, 106: ) 107: if ('error' in persisted) { 108: return { 109: uri: c.uri, 110: mimeType: c.mimeType, 111: text: `Binary content could not be saved to disk: ${persisted.error}`, 112: } 113: } 114: return { 115: uri: c.uri, 116: mimeType: c.mimeType, 117: blobSavedTo: persisted.filepath, 118: text: getBinaryBlobSavedMessage( 119: persisted.filepath, 120: c.mimeType, 121: persisted.size, 122: `[Resource from ${serverName} at ${c.uri}] `, 123: ), 124: } 125: }), 126: ) 127: return { 128: data: { contents }, 129: } 130: }, 131: renderToolUseMessage, 132: userFacingName, 133: renderToolResultMessage, 134: isResultTruncated(output: Output): boolean { 135: return isOutputLineTruncated(jsonStringify(output)) 136: }, 137: mapToolResultToToolResultBlockParam(content, toolUseID) { 138: return { 139: tool_use_id: toolUseID, 140: type: 'tool_result', 141: content: jsonStringify(content), 142: } 143: }, 144: } satisfies ToolDef<InputSchema, Output>)

File: src/tools/ReadMcpResourceTool/UI.tsx

typescript 1: import * as React from 'react'; 2: import type { z } from 'zod/v4'; 3: import { MessageResponse } from '../../components/MessageResponse.js'; 4: import { OutputLine } from '../../components/shell/OutputLine.js'; 5: import { Box, Text } from '../../ink.js'; 6: import type { ToolProgressData } from '../../Tool.js'; 7: import type { ProgressMessage } from '../../types/message.js'; 8: import { jsonStringify } from '../../utils/slowOperations.js'; 9: import type { inputSchema, Output } from './ReadMcpResourceTool.js'; 10: export function renderToolUseMessage(input: Partial<z.infer<ReturnType<typeof inputSchema>>>): React.ReactNode { 11: if (!input.uri || !input.server) { 12: return null; 13: } 14: return `Read resource "${input.uri}" from server "${input.server}"`; 15: } 16: export function userFacingName(): string { 17: return 'readMcpResource'; 18: } 19: export function renderToolResultMessage(output: Output, _progressMessagesForMessage: ProgressMessage<ToolProgressData>[], { 20: verbose 21: }: { 22: verbose: boolean; 23: }): React.ReactNode { 24: if (!output || !output.contents || output.contents.length === 0) { 25: return <Box justifyContent="space-between" overflowX="hidden" width="100%"> 26: <MessageResponse height={1}> 27: <Text dimColor>(No content)</Text> 28: </MessageResponse> 29: </Box>; 30: } 31: const formattedOutput = jsonStringify(output, null, 2); 32: return <OutputLine content={formattedOutput} verbose={verbose} />; 33: }

File: src/tools/RemoteTriggerTool/prompt.ts

typescript 1: export const REMOTE_TRIGGER_TOOL_NAME = 'RemoteTrigger' 2: export const DESCRIPTION = 3: 'Manage scheduled remote Claude Code agents (triggers) via the claude.ai CCR API. Auth is handled in-process — the token never reaches the shell.' 4: export const PROMPT = `Call the claude.ai remote-trigger API. Use this instead of curl — the OAuth token is added automatically in-process and never exposed. 5: Actions: 6: - list: GET /v1/code/triggers 7: - get: GET /v1/code/triggers/{trigger_id} 8: - create: POST /v1/code/triggers (requires body) 9: - update: POST /v1/code/triggers/{trigger_id} (requires body, partial update) 10: - run: POST /v1/code/triggers/{trigger_id}/run 11: The response is the raw JSON from the API.`

File: src/tools/RemoteTriggerTool/RemoteTriggerTool.ts

typescript 1: import axios from 'axios' 2: import { z } from 'zod/v4' 3: import { getOauthConfig } from '../../constants/oauth.js' 4: import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js' 5: import { getOrganizationUUID } from '../../services/oauth/client.js' 6: import { isPolicyAllowed } from '../../services/policyLimits/index.js' 7: import type { ToolUseContext } from '../../Tool.js' 8: import { buildTool, type ToolDef } from '../../Tool.js' 9: import { 10: checkAndRefreshOAuthTokenIfNeeded, 11: getClaudeAIOAuthTokens, 12: } from '../../utils/auth.js' 13: import { lazySchema } from '../../utils/lazySchema.js' 14: import { jsonStringify } from '../../utils/slowOperations.js' 15: import { DESCRIPTION, PROMPT, REMOTE_TRIGGER_TOOL_NAME } from './prompt.js' 16: import { renderToolResultMessage, renderToolUseMessage } from './UI.js' 17: const inputSchema = lazySchema(() => 18: z.strictObject({ 19: action: z.enum(['list', 'get', 'create', 'update', 'run']), 20: trigger_id: z 21: .string() 22: .regex(/^[\w-]+$/) 23: .optional() 24: .describe('Required for get, update, and run'), 25: body: z 26: .record(z.string(), z.unknown()) 27: .optional() 28: .describe('JSON body for create and update'), 29: }), 30: ) 31: type InputSchema = ReturnType<typeof inputSchema> 32: export type Input = z.infer<InputSchema> 33: const outputSchema = lazySchema(() => 34: z.object({ 35: status: z.number(), 36: json: z.string(), 37: }), 38: ) 39: type OutputSchema = ReturnType<typeof outputSchema> 40: export type Output = z.infer<OutputSchema> 41: const TRIGGERS_BETA = 'ccr-triggers-2026-01-30' 42: export const RemoteTriggerTool = buildTool({ 43: name: REMOTE_TRIGGER_TOOL_NAME, 44: searchHint: 'manage scheduled remote agent triggers', 45: maxResultSizeChars: 100_000, 46: shouldDefer: true, 47: get inputSchema(): InputSchema { 48: return inputSchema() 49: }, 50: get outputSchema(): OutputSchema { 51: return outputSchema() 52: }, 53: isEnabled() { 54: return ( 55: getFeatureValue_CACHED_MAY_BE_STALE('tengu_surreal_dali', false) && 56: isPolicyAllowed('allow_remote_sessions') 57: ) 58: }, 59: isConcurrencySafe() { 60: return true 61: }, 62: isReadOnly(input: Input) { 63: return input.action === 'list' || input.action === 'get' 64: }, 65: toAutoClassifierInput(input: Input) { 66: return `RemoteTrigger ${input.action}${input.trigger_id ? ` ${input.trigger_id}` : ''}` 67: }, 68: async description() { 69: return DESCRIPTION 70: }, 71: async prompt() { 72: return PROMPT 73: }, 74: async call(input: Input, context: ToolUseContext) { 75: await checkAndRefreshOAuthTokenIfNeeded() 76: const accessToken = getClaudeAIOAuthTokens()?.accessToken 77: if (!accessToken) { 78: throw new Error( 79: 'Not authenticated with a claude.ai account. Run /login and try again.', 80: ) 81: } 82: const orgUUID = await getOrganizationUUID() 83: if (!orgUUID) { 84: throw new Error('Unable to resolve organization UUID.') 85: } 86: const base = `${getOauthConfig().BASE_API_URL}/v1/code/triggers` 87: const headers = { 88: Authorization: `Bearer ${accessToken}`, 89: 'Content-Type': 'application/json', 90: 'anthropic-version': '2023-06-01', 91: 'anthropic-beta': TRIGGERS_BETA, 92: 'x-organization-uuid': orgUUID, 93: } 94: const { action, trigger_id, body } = input 95: let method: 'GET' | 'POST' 96: let url: string 97: let data: unknown 98: switch (action) { 99: case 'list': 100: method = 'GET' 101: url = base 102: break 103: case 'get': 104: if (!trigger_id) throw new Error('get requires trigger_id') 105: method = 'GET' 106: url = `${base}/${trigger_id}` 107: break 108: case 'create': 109: if (!body) throw new Error('create requires body') 110: method = 'POST' 111: url = base 112: data = body 113: break 114: case 'update': 115: if (!trigger_id) throw new Error('update requires trigger_id') 116: if (!body) throw new Error('update requires body') 117: method = 'POST' 118: url = `${base}/${trigger_id}` 119: data = body 120: break 121: case 'run': 122: if (!trigger_id) throw new Error('run requires trigger_id') 123: method = 'POST' 124: url = `${base}/${trigger_id}/run` 125: data = {} 126: break 127: } 128: const res = await axios.request({ 129: method, 130: url, 131: headers, 132: data, 133: timeout: 20_000, 134: signal: context.abortController.signal, 135: validateStatus: () => true, 136: }) 137: return { 138: data: { 139: status: res.status, 140: json: jsonStringify(res.data), 141: }, 142: } 143: }, 144: mapToolResultToToolResultBlockParam(output, toolUseID) { 145: return { 146: tool_use_id: toolUseID, 147: type: 'tool_result', 148: content: `HTTP ${output.status}\n${output.json}`, 149: } 150: }, 151: renderToolUseMessage, 152: renderToolResultMessage, 153: } satisfies ToolDef<InputSchema, Output>)

File: src/tools/RemoteTriggerTool/UI.tsx

typescript 1: import React from 'react'; 2: import { MessageResponse } from '../../components/MessageResponse.js'; 3: import { Text } from '../../ink.js'; 4: import { countCharInString } from '../../utils/stringUtils.js'; 5: import type { Input, Output } from './RemoteTriggerTool.js'; 6: export function renderToolUseMessage(input: Partial<Input>): React.ReactNode { 7: return `${input.action ?? ''}${input.trigger_id ? ` ${input.trigger_id}` : ''}`; 8: } 9: export function renderToolResultMessage(output: Output): React.ReactNode { 10: const lines = countCharInString(output.json, '\n') + 1; 11: return <MessageResponse> 12: <Text> 13: HTTP {output.status} <Text dimColor>({lines} lines)</Text> 14: </Text> 15: </MessageResponse>; 16: }

File: src/tools/REPLTool/constants.ts

typescript 1: import { isEnvDefinedFalsy, isEnvTruthy } from '../../utils/envUtils.js' 2: import { AGENT_TOOL_NAME } from '../AgentTool/constants.js' 3: import { BASH_TOOL_NAME } from '../BashTool/toolName.js' 4: import { FILE_EDIT_TOOL_NAME } from '../FileEditTool/constants.js' 5: import { FILE_READ_TOOL_NAME } from '../FileReadTool/prompt.js' 6: import { FILE_WRITE_TOOL_NAME } from '../FileWriteTool/prompt.js' 7: import { GLOB_TOOL_NAME } from '../GlobTool/prompt.js' 8: import { GREP_TOOL_NAME } from '../GrepTool/prompt.js' 9: import { NOTEBOOK_EDIT_TOOL_NAME } from '../NotebookEditTool/constants.js' 10: export const REPL_TOOL_NAME = 'REPL' 11: export function isReplModeEnabled(): boolean { 12: if (isEnvDefinedFalsy(process.env.CLAUDE_CODE_REPL)) return false 13: if (isEnvTruthy(process.env.CLAUDE_REPL_MODE)) return true 14: return ( 15: process.env.USER_TYPE === 'ant' && 16: process.env.CLAUDE_CODE_ENTRYPOINT === 'cli' 17: ) 18: } 19: export const REPL_ONLY_TOOLS = new Set([ 20: FILE_READ_TOOL_NAME, 21: FILE_WRITE_TOOL_NAME, 22: FILE_EDIT_TOOL_NAME, 23: GLOB_TOOL_NAME, 24: GREP_TOOL_NAME, 25: BASH_TOOL_NAME, 26: NOTEBOOK_EDIT_TOOL_NAME, 27: AGENT_TOOL_NAME, 28: ])

File: src/tools/REPLTool/primitiveTools.ts

typescript 1: import type { Tool } from '../../Tool.js' 2: import { AgentTool } from '../AgentTool/AgentTool.js' 3: import { BashTool } from '../BashTool/BashTool.js' 4: import { FileEditTool } from '../FileEditTool/FileEditTool.js' 5: import { FileReadTool } from '../FileReadTool/FileReadTool.js' 6: import { FileWriteTool } from '../FileWriteTool/FileWriteTool.js' 7: import { GlobTool } from '../GlobTool/GlobTool.js' 8: import { GrepTool } from '../GrepTool/GrepTool.js' 9: import { NotebookEditTool } from '../NotebookEditTool/NotebookEditTool.js' 10: let _primitiveTools: readonly Tool[] | undefined 11: export function getReplPrimitiveTools(): readonly Tool[] { 12: return (_primitiveTools ??= [ 13: FileReadTool, 14: FileWriteTool, 15: FileEditTool, 16: GlobTool, 17: GrepTool, 18: BashTool, 19: NotebookEditTool, 20: AgentTool, 21: ]) 22: }

File: src/tools/ScheduleCronTool/CronCreateTool.ts

typescript 1: import { z } from 'zod/v4' 2: import { setScheduledTasksEnabled } from '../../bootstrap/state.js' 3: import type { ValidationResult } from '../../Tool.js' 4: import { buildTool, type ToolDef } from '../../Tool.js' 5: import { cronToHuman, parseCronExpression } from '../../utils/cron.js' 6: import { 7: addCronTask, 8: getCronFilePath, 9: listAllCronTasks, 10: nextCronRunMs, 11: } from '../../utils/cronTasks.js' 12: import { lazySchema } from '../../utils/lazySchema.js' 13: import { semanticBoolean } from '../../utils/semanticBoolean.js' 14: import { getTeammateContext } from '../../utils/teammateContext.js' 15: import { 16: buildCronCreateDescription, 17: buildCronCreatePrompt, 18: CRON_CREATE_TOOL_NAME, 19: DEFAULT_MAX_AGE_DAYS, 20: isDurableCronEnabled, 21: isKairosCronEnabled, 22: } from './prompt.js' 23: import { renderCreateResultMessage, renderCreateToolUseMessage } from './UI.js' 24: const MAX_JOBS = 50 25: const inputSchema = lazySchema(() => 26: z.strictObject({ 27: cron: z 28: .string() 29: .describe( 30: 'Standard 5-field cron expression in local time: "M H DoM Mon DoW" (e.g. "*/5 * * * *" = every 5 minutes, "30 14 28 2 *" = Feb 28 at 2:30pm local once).', 31: ), 32: prompt: z.string().describe('The prompt to enqueue at each fire time.'), 33: recurring: semanticBoolean(z.boolean().optional()).describe( 34: `true (default) = fire on every cron match until deleted or auto-expired after ${DEFAULT_MAX_AGE_DAYS} days. false = fire once at the next match, then auto-delete. Use false for "remind me at X" one-shot requests with pinned minute/hour/dom/month.`, 35: ), 36: durable: semanticBoolean(z.boolean().optional()).describe( 37: 'true = persist to .claude/scheduled_tasks.json and survive restarts. false (default) = in-memory only, dies when this Claude session ends. Use true only when the user asks the task to survive across sessions.', 38: ), 39: }), 40: ) 41: type InputSchema = ReturnType<typeof inputSchema> 42: const outputSchema = lazySchema(() => 43: z.object({ 44: id: z.string(), 45: humanSchedule: z.string(), 46: recurring: z.boolean(), 47: durable: z.boolean().optional(), 48: }), 49: ) 50: type OutputSchema = ReturnType<typeof outputSchema> 51: export type CreateOutput = z.infer<OutputSchema> 52: export const CronCreateTool = buildTool({ 53: name: CRON_CREATE_TOOL_NAME, 54: searchHint: 'schedule a recurring or one-shot prompt', 55: maxResultSizeChars: 100_000, 56: shouldDefer: true, 57: get inputSchema(): InputSchema { 58: return inputSchema() 59: }, 60: get outputSchema(): OutputSchema { 61: return outputSchema() 62: }, 63: isEnabled() { 64: return isKairosCronEnabled() 65: }, 66: toAutoClassifierInput(input) { 67: return `${input.cron}: ${input.prompt}` 68: }, 69: async description() { 70: return buildCronCreateDescription(isDurableCronEnabled()) 71: }, 72: async prompt() { 73: return buildCronCreatePrompt(isDurableCronEnabled()) 74: }, 75: getPath() { 76: return getCronFilePath() 77: }, 78: async validateInput(input): Promise<ValidationResult> { 79: if (!parseCronExpression(input.cron)) { 80: return { 81: result: false, 82: message: `Invalid cron expression '${input.cron}'. Expected 5 fields: M H DoM Mon DoW.`, 83: errorCode: 1, 84: } 85: } 86: if (nextCronRunMs(input.cron, Date.now()) === null) { 87: return { 88: result: false, 89: message: `Cron expression '${input.cron}' does not match any calendar date in the next year.`, 90: errorCode: 2, 91: } 92: } 93: const tasks = await listAllCronTasks() 94: if (tasks.length >= MAX_JOBS) { 95: return { 96: result: false, 97: message: `Too many scheduled jobs (max ${MAX_JOBS}). Cancel one first.`, 98: errorCode: 3, 99: } 100: } 101: if (input.durable && getTeammateContext()) { 102: return { 103: result: false, 104: message: 105: 'durable crons are not supported for teammates (teammates do not persist across sessions)', 106: errorCode: 4, 107: } 108: } 109: return { result: true } 110: }, 111: async call({ cron, prompt, recurring = true, durable = false }) { 112: const effectiveDurable = durable && isDurableCronEnabled() 113: const id = await addCronTask( 114: cron, 115: prompt, 116: recurring, 117: effectiveDurable, 118: getTeammateContext()?.agentId, 119: ) 120: setScheduledTasksEnabled(true) 121: return { 122: data: { 123: id, 124: humanSchedule: cronToHuman(cron), 125: recurring, 126: durable: effectiveDurable, 127: }, 128: } 129: }, 130: mapToolResultToToolResultBlockParam(output, toolUseID) { 131: const where = output.durable 132: ? 'Persisted to .claude/scheduled_tasks.json' 133: : 'Session-only (not written to disk, dies when Claude exits)' 134: return { 135: tool_use_id: toolUseID, 136: type: 'tool_result', 137: content: output.recurring 138: ? `Scheduled recurring job ${output.id} (${output.humanSchedule}). ${where}. Auto-expires after ${DEFAULT_MAX_AGE_DAYS} days. Use CronDelete to cancel sooner.` 139: : `Scheduled one-shot task ${output.id} (${output.humanSchedule}). ${where}. It will fire once then auto-delete.`, 140: } 141: }, 142: renderToolUseMessage: renderCreateToolUseMessage, 143: renderToolResultMessage: renderCreateResultMessage, 144: } satisfies ToolDef<InputSchema, CreateOutput>)

File: src/tools/ScheduleCronTool/CronDeleteTool.ts

typescript 1: import { z } from 'zod/v4' 2: import type { ValidationResult } from '../../Tool.js' 3: import { buildTool, type ToolDef } from '../../Tool.js' 4: import { 5: getCronFilePath, 6: listAllCronTasks, 7: removeCronTasks, 8: } from '../../utils/cronTasks.js' 9: import { lazySchema } from '../../utils/lazySchema.js' 10: import { getTeammateContext } from '../../utils/teammateContext.js' 11: import { 12: buildCronDeletePrompt, 13: CRON_DELETE_DESCRIPTION, 14: CRON_DELETE_TOOL_NAME, 15: isDurableCronEnabled, 16: isKairosCronEnabled, 17: } from './prompt.js' 18: import { renderDeleteResultMessage, renderDeleteToolUseMessage } from './UI.js' 19: const inputSchema = lazySchema(() => 20: z.strictObject({ 21: id: z.string().describe('Job ID returned by CronCreate.'), 22: }), 23: ) 24: type InputSchema = ReturnType<typeof inputSchema> 25: const outputSchema = lazySchema(() => 26: z.object({ 27: id: z.string(), 28: }), 29: ) 30: type OutputSchema = ReturnType<typeof outputSchema> 31: export type DeleteOutput = z.infer<OutputSchema> 32: export const CronDeleteTool = buildTool({ 33: name: CRON_DELETE_TOOL_NAME, 34: searchHint: 'cancel a scheduled cron job', 35: maxResultSizeChars: 100_000, 36: shouldDefer: true, 37: get inputSchema(): InputSchema { 38: return inputSchema() 39: }, 40: get outputSchema(): OutputSchema { 41: return outputSchema() 42: }, 43: isEnabled() { 44: return isKairosCronEnabled() 45: }, 46: toAutoClassifierInput(input) { 47: return input.id 48: }, 49: async description() { 50: return CRON_DELETE_DESCRIPTION 51: }, 52: async prompt() { 53: return buildCronDeletePrompt(isDurableCronEnabled()) 54: }, 55: getPath() { 56: return getCronFilePath() 57: }, 58: async validateInput(input): Promise<ValidationResult> { 59: const tasks = await listAllCronTasks() 60: const task = tasks.find(t => t.id === input.id) 61: if (!task) { 62: return { 63: result: false, 64: message: `No scheduled job with id '${input.id}'`, 65: errorCode: 1, 66: } 67: } 68: const ctx = getTeammateContext() 69: if (ctx && task.agentId !== ctx.agentId) { 70: return { 71: result: false, 72: message: `Cannot delete cron job '${input.id}': owned by another agent`, 73: errorCode: 2, 74: } 75: } 76: return { result: true } 77: }, 78: async call({ id }) { 79: await removeCronTasks([id]) 80: return { data: { id } } 81: }, 82: mapToolResultToToolResultBlockParam(output, toolUseID) { 83: return { 84: tool_use_id: toolUseID, 85: type: 'tool_result', 86: content: `Cancelled job ${output.id}.`, 87: } 88: }, 89: renderToolUseMessage: renderDeleteToolUseMessage, 90: renderToolResultMessage: renderDeleteResultMessage, 91: } satisfies ToolDef<InputSchema, DeleteOutput>)

File: src/tools/ScheduleCronTool/CronListTool.ts

typescript 1: import { z } from 'zod/v4' 2: import { buildTool, type ToolDef } from '../../Tool.js' 3: import { cronToHuman } from '../../utils/cron.js' 4: import { listAllCronTasks } from '../../utils/cronTasks.js' 5: import { truncate } from '../../utils/format.js' 6: import { lazySchema } from '../../utils/lazySchema.js' 7: import { getTeammateContext } from '../../utils/teammateContext.js' 8: import { 9: buildCronListPrompt, 10: CRON_LIST_DESCRIPTION, 11: CRON_LIST_TOOL_NAME, 12: isDurableCronEnabled, 13: isKairosCronEnabled, 14: } from './prompt.js' 15: import { renderListResultMessage, renderListToolUseMessage } from './UI.js' 16: const inputSchema = lazySchema(() => z.strictObject({})) 17: type InputSchema = ReturnType<typeof inputSchema> 18: const outputSchema = lazySchema(() => 19: z.object({ 20: jobs: z.array( 21: z.object({ 22: id: z.string(), 23: cron: z.string(), 24: humanSchedule: z.string(), 25: prompt: z.string(), 26: recurring: z.boolean().optional(), 27: durable: z.boolean().optional(), 28: }), 29: ), 30: }), 31: ) 32: type OutputSchema = ReturnType<typeof outputSchema> 33: export type ListOutput = z.infer<OutputSchema> 34: export const CronListTool = buildTool({ 35: name: CRON_LIST_TOOL_NAME, 36: searchHint: 'list active cron jobs', 37: maxResultSizeChars: 100_000, 38: shouldDefer: true, 39: get inputSchema(): InputSchema { 40: return inputSchema() 41: }, 42: get outputSchema(): OutputSchema { 43: return outputSchema() 44: }, 45: isEnabled() { 46: return isKairosCronEnabled() 47: }, 48: isConcurrencySafe() { 49: return true 50: }, 51: isReadOnly() { 52: return true 53: }, 54: async description() { 55: return CRON_LIST_DESCRIPTION 56: }, 57: async prompt() { 58: return buildCronListPrompt(isDurableCronEnabled()) 59: }, 60: async call() { 61: const allTasks = await listAllCronTasks() 62: const ctx = getTeammateContext() 63: const tasks = ctx 64: ? allTasks.filter(t => t.agentId === ctx.agentId) 65: : allTasks 66: const jobs = tasks.map(t => ({ 67: id: t.id, 68: cron: t.cron, 69: humanSchedule: cronToHuman(t.cron), 70: prompt: t.prompt, 71: ...(t.recurring ? { recurring: true } : {}), 72: ...(t.durable === false ? { durable: false } : {}), 73: })) 74: return { data: { jobs } } 75: }, 76: mapToolResultToToolResultBlockParam(output, toolUseID) { 77: return { 78: tool_use_id: toolUseID, 79: type: 'tool_result', 80: content: 81: output.jobs.length > 0 82: ? output.jobs 83: .map( 84: j => 85: `${j.id} — ${j.humanSchedule}${j.recurring ? ' (recurring)' : ' (one-shot)'}${j.durable === false ? ' [session-only]' : ''}: ${truncate(j.prompt, 80, true)}`, 86: ) 87: .join('\n') 88: : 'No scheduled jobs.', 89: } 90: }, 91: renderToolUseMessage: renderListToolUseMessage, 92: renderToolResultMessage: renderListResultMessage, 93: } satisfies ToolDef<InputSchema, ListOutput>)

File: src/tools/ScheduleCronTool/prompt.ts

typescript 1: import { feature } from 'bun:bundle' 2: import { getFeatureValue_CACHED_WITH_REFRESH } from '../../services/analytics/growthbook.js' 3: import { DEFAULT_CRON_JITTER_CONFIG } from '../../utils/cronTasks.js' 4: import { isEnvTruthy } from '../../utils/envUtils.js' 5: const KAIROS_CRON_REFRESH_MS = 5 * 60 * 1000 6: export const DEFAULT_MAX_AGE_DAYS = 7: DEFAULT_CRON_JITTER_CONFIG.recurringMaxAgeMs / (24 * 60 * 60 * 1000) 8: export function isKairosCronEnabled(): boolean { 9: return feature('AGENT_TRIGGERS') 10: ? !isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_CRON) && 11: getFeatureValue_CACHED_WITH_REFRESH( 12: 'tengu_kairos_cron', 13: true, 14: KAIROS_CRON_REFRESH_MS, 15: ) 16: : false 17: } 18: export function isDurableCronEnabled(): boolean { 19: return getFeatureValue_CACHED_WITH_REFRESH( 20: 'tengu_kairos_cron_durable', 21: true, 22: KAIROS_CRON_REFRESH_MS, 23: ) 24: } 25: export const CRON_CREATE_TOOL_NAME = 'CronCreate' 26: export const CRON_DELETE_TOOL_NAME = 'CronDelete' 27: export const CRON_LIST_TOOL_NAME = 'CronList' 28: export function buildCronCreateDescription(durableEnabled: boolean): string { 29: return durableEnabled 30: ? 'Schedule a prompt to run at a future time — either recurring on a cron schedule, or once at a specific time. Pass durable: true to persist to .claude/scheduled_tasks.json; otherwise session-only.' 31: : 'Schedule a prompt to run at a future time within this Claude session — either recurring on a cron schedule, or once at a specific time.' 32: } 33: export function buildCronCreatePrompt(durableEnabled: boolean): string { 34: const durabilitySection = durableEnabled 35: ? `## Durability 36: By default (durable: false) the job lives only in this Claude session — nothing is written to disk, and the job is gone when Claude exits. Pass durable: true to write to .claude/scheduled_tasks.json so the job survives restarts. Only use durable: true when the user explicitly asks for the task to persist ("keep doing this every day", "set this up permanently"). Most "remind me in 5 minutes" / "check back in an hour" requests should stay session-only.` 37: : `## Session-only 38: Jobs live only in this Claude session — nothing is written to disk, and the job is gone when Claude exits.` 39: const durableRuntimeNote = durableEnabled 40: ? 'Durable jobs persist to .claude/scheduled_tasks.json and survive session restarts — on next launch they resume automatically. One-shot durable tasks that were missed while the REPL was closed are surfaced for catch-up. Session-only jobs die with the process. ' 41: : '' 42: return `Schedule a prompt to be enqueued at a future time. Use for both recurring schedules and one-shot reminders. 43: Uses standard 5-field cron in the user's local timezone: minute hour day-of-month month day-of-week. "0 9 * * *" means 9am local — no timezone conversion needed. 44: ## One-shot tasks (recurring: false) 45: For "remind me at X" or "at <time>, do Y" requests — fire once then auto-delete. 46: Pin minute/hour/day-of-month/month to specific values: 47: "remind me at 2:30pm today to check the deploy" → cron: "30 14 <today_dom> <today_month> *", recurring: false 48: "tomorrow morning, run the smoke test" → cron: "57 8 <tomorrow_dom> <tomorrow_month> *", recurring: false 49: ## Recurring jobs (recurring: true, the default) 50: For "every N minutes" / "every hour" / "weekdays at 9am" requests: 51: "*/5 * * * *" (every 5 min), "0 * * * *" (hourly), "0 9 * * 1-5" (weekdays at 9am local) 52: ## Avoid the :00 and :30 minute marks when the task allows it 53: Every user who asks for "9am" gets \`0 9\`, and every user who asks for "hourly" gets \`0 *\` — which means requests from across the planet land on the API at the same instant. When the user's request is approximate, pick a minute that is NOT 0 or 30: 54: "every morning around 9" → "57 8 * * *" or "3 9 * * *" (not "0 9 * * *") 55: "hourly" → "7 * * * *" (not "0 * * * *") 56: "in an hour or so, remind me to..." → pick whatever minute you land on, don't round 57: Only use minute 0 or 30 when the user names that exact time and clearly means it ("at 9:00 sharp", "at half past", coordinating with a meeting). When in doubt, nudge a few minutes early or late — the user will not notice, and the fleet will. 58: ${durabilitySection} 59: ## Runtime behavior 60: Jobs only fire while the REPL is idle (not mid-query). ${durableRuntimeNote}The scheduler adds a small deterministic jitter on top of whatever you pick: recurring tasks fire up to 10% of their period late (max 15 min); one-shot tasks landing on :00 or :30 fire up to 90 s early. Picking an off-minute is still the bigger lever. 61: Recurring tasks auto-expire after ${DEFAULT_MAX_AGE_DAYS} days — they fire one final time, then are deleted. This bounds session lifetime. Tell the user about the ${DEFAULT_MAX_AGE_DAYS}-day limit when scheduling recurring jobs. 62: Returns a job ID you can pass to ${CRON_DELETE_TOOL_NAME}.` 63: } 64: export const CRON_DELETE_DESCRIPTION = 'Cancel a scheduled cron job by ID' 65: export function buildCronDeletePrompt(durableEnabled: boolean): string { 66: return durableEnabled 67: ? `Cancel a cron job previously scheduled with ${CRON_CREATE_TOOL_NAME}. Removes it from .claude/scheduled_tasks.json (durable jobs) or the in-memory session store (session-only jobs).` 68: : `Cancel a cron job previously scheduled with ${CRON_CREATE_TOOL_NAME}. Removes it from the in-memory session store.` 69: } 70: export const CRON_LIST_DESCRIPTION = 'List scheduled cron jobs' 71: export function buildCronListPrompt(durableEnabled: boolean): string { 72: return durableEnabled 73: ? `List all cron jobs scheduled via ${CRON_CREATE_TOOL_NAME}, both durable (.claude/scheduled_tasks.json) and session-only.` 74: : `List all cron jobs scheduled via ${CRON_CREATE_TOOL_NAME} in this session.` 75: }

File: src/tools/ScheduleCronTool/UI.tsx

typescript 1: import React from 'react'; 2: import { MessageResponse } from '../../components/MessageResponse.js'; 3: import { Text } from '../../ink.js'; 4: import { truncate } from '../../utils/format.js'; 5: import type { CreateOutput } from './CronCreateTool.js'; 6: import type { DeleteOutput } from './CronDeleteTool.js'; 7: import type { ListOutput } from './CronListTool.js'; 8: export function renderCreateToolUseMessage(input: Partial<{ 9: cron: string; 10: prompt: string; 11: }>): React.ReactNode { 12: return `${input.cron ?? ''}${input.prompt ? `: ${truncate(input.prompt, 60, true)}` : ''}`; 13: } 14: export function renderCreateResultMessage(output: CreateOutput): React.ReactNode { 15: return <MessageResponse> 16: <Text> 17: Scheduled <Text bold>{output.id}</Text>{' '} 18: <Text dimColor>({output.humanSchedule})</Text> 19: </Text> 20: </MessageResponse>; 21: } 22: export function renderDeleteToolUseMessage(input: Partial<{ 23: id: string; 24: }>): React.ReactNode { 25: return input.id ?? ''; 26: } 27: export function renderDeleteResultMessage(output: DeleteOutput): React.ReactNode { 28: return <MessageResponse> 29: <Text> 30: Cancelled <Text bold>{output.id}</Text> 31: </Text> 32: </MessageResponse>; 33: } 34: // --- CronList --------------------------------------------------------------- 35: export function renderListToolUseMessage(): React.ReactNode { 36: return ''; 37: } 38: export function renderListResultMessage(output: ListOutput): React.ReactNode { 39: if (output.jobs.length === 0) { 40: return <MessageResponse> 41: <Text dimColor>No scheduled jobs</Text> 42: </MessageResponse>; 43: } 44: return <MessageResponse> 45: {output.jobs.map(j => <Text key={j.id}> 46: <Text bold>{j.id}</Text> <Text dimColor>{j.humanSchedule}</Text> 47: </Text>)} 48: </MessageResponse>; 49: }

File: src/tools/SendMessageTool/constants.ts

typescript 1: export const SEND_MESSAGE_TOOL_NAME = 'SendMessage'

File: src/tools/SendMessageTool/prompt.ts

typescript 1: import { feature } from 'bun:bundle' 2: export const DESCRIPTION = 'Send a message to another agent' 3: export function getPrompt(): string { 4: const udsRow = feature('UDS_INBOX') 5: ? `\n| \`"uds:/path/to.sock"\` | Local Claude session's socket (same machine; use \`ListPeers\`) | 6: | \`"bridge:session_..."\` | Remote Control peer session (cross-machine; use \`ListPeers\`) |` 7: : '' 8: const udsSection = feature('UDS_INBOX') 9: ? `\n\n## Cross-session 10: Use \`ListPeers\` to discover targets, then: 11: \`\`\`json 12: {"to": "uds:/tmp/cc-socks/1234.sock", "message": "check if tests pass over there"} 13: {"to": "bridge:session_01AbCd...", "message": "what branch are you on?"} 14: \`\`\` 15: A listed peer is alive and will process your message — no "busy" state; messages enqueue and drain at the receiver's next tool round. Your message arrives wrapped as \`<cross-session-message from="...">\`. **To reply to an incoming message, copy its \`from\` attribute as your \`to\`.**` 16: : '' 17: return ` 18: # SendMessage 19: Send a message to another agent. 20: \`\`\`json 21: {"to": "researcher", "summary": "assign task 1", "message": "start on task #1"} 22: \`\`\` 23: | \`to\` | | 24: |---|---| 25: | \`"researcher"\` | Teammate by name | 26: | \`"*"\` | Broadcast to all teammates — expensive (linear in team size), use only when everyone genuinely needs it |${udsRow} 27: Your plain text output is NOT visible to other agents — to communicate, you MUST call this tool. Messages from teammates are delivered automatically; you don't check an inbox. Refer to teammates by name, never by UUID. When relaying, don't quote the original — it's already rendered to the user.${udsSection} 28: ## Protocol responses (legacy) 29: If you receive a JSON message with \`type: "shutdown_request"\` or \`type: "plan_approval_request"\`, respond with the matching \`_response\` type — echo the \`request_id\`, set \`approve\` true/false: 30: \`\`\`json 31: {"to": "team-lead", "message": {"type": "shutdown_response", "request_id": "...", "approve": true}} 32: {"to": "researcher", "message": {"type": "plan_approval_response", "request_id": "...", "approve": false, "feedback": "add error handling"}} 33: \`\`\` 34: Approving shutdown terminates your process. Rejecting plan sends the teammate back to revise. Don't originate \`shutdown_request\` unless asked. Don't send structured JSON status messages — use TaskUpdate. 35: `.trim() 36: }

File: src/tools/SendMessageTool/SendMessageTool.ts

typescript 1: import { feature } from 'bun:bundle' 2: import { z } from 'zod/v4' 3: import { isReplBridgeActive } from '../../bootstrap/state.js' 4: import { getReplBridgeHandle } from '../../bridge/replBridgeHandle.js' 5: import type { Tool, ToolUseContext } from '../../Tool.js' 6: import { buildTool, type ToolDef } from '../../Tool.js' 7: import { findTeammateTaskByAgentId } from '../../tasks/InProcessTeammateTask/InProcessTeammateTask.js' 8: import { 9: isLocalAgentTask, 10: queuePendingMessage, 11: } from '../../tasks/LocalAgentTask/LocalAgentTask.js' 12: import { isMainSessionTask } from '../../tasks/LocalMainSessionTask.js' 13: import { toAgentId } from '../../types/ids.js' 14: import { generateRequestId } from '../../utils/agentId.js' 15: import { isAgentSwarmsEnabled } from '../../utils/agentSwarmsEnabled.js' 16: import { logForDebugging } from '../../utils/debug.js' 17: import { errorMessage } from '../../utils/errors.js' 18: import { truncate } from '../../utils/format.js' 19: import { gracefulShutdown } from '../../utils/gracefulShutdown.js' 20: import { lazySchema } from '../../utils/lazySchema.js' 21: import { parseAddress } from '../../utils/peerAddress.js' 22: import { semanticBoolean } from '../../utils/semanticBoolean.js' 23: import { jsonStringify } from '../../utils/slowOperations.js' 24: import type { BackendType } from '../../utils/swarm/backends/types.js' 25: import { TEAM_LEAD_NAME } from '../../utils/swarm/constants.js' 26: import { readTeamFileAsync } from '../../utils/swarm/teamHelpers.js' 27: import { 28: getAgentId, 29: getAgentName, 30: getTeammateColor, 31: getTeamName, 32: isTeamLead, 33: isTeammate, 34: } from '../../utils/teammate.js' 35: import { 36: createShutdownApprovedMessage, 37: createShutdownRejectedMessage, 38: createShutdownRequestMessage, 39: writeToMailbox, 40: } from '../../utils/teammateMailbox.js' 41: import { resumeAgentBackground } from '../AgentTool/resumeAgent.js' 42: import { SEND_MESSAGE_TOOL_NAME } from './constants.js' 43: import { DESCRIPTION, getPrompt } from './prompt.js' 44: import { renderToolResultMessage, renderToolUseMessage } from './UI.js' 45: const StructuredMessage = lazySchema(() => 46: z.discriminatedUnion('type', [ 47: z.object({ 48: type: z.literal('shutdown_request'), 49: reason: z.string().optional(), 50: }), 51: z.object({ 52: type: z.literal('shutdown_response'), 53: request_id: z.string(), 54: approve: semanticBoolean(), 55: reason: z.string().optional(), 56: }), 57: z.object({ 58: type: z.literal('plan_approval_response'), 59: request_id: z.string(), 60: approve: semanticBoolean(), 61: feedback: z.string().optional(), 62: }), 63: ]), 64: ) 65: const inputSchema = lazySchema(() => 66: z.object({ 67: to: z 68: .string() 69: .describe( 70: feature('UDS_INBOX') 71: ? 'Recipient: teammate name, "*" for broadcast, "uds:<socket-path>" for a local peer, or "bridge:<session-id>" for a Remote Control peer (use ListPeers to discover)' 72: : 'Recipient: teammate name, or "*" for broadcast to all teammates', 73: ), 74: summary: z 75: .string() 76: .optional() 77: .describe( 78: 'A 5-10 word summary shown as a preview in the UI (required when message is a string)', 79: ), 80: message: z.union([ 81: z.string().describe('Plain text message content'), 82: StructuredMessage(), 83: ]), 84: }), 85: ) 86: type InputSchema = ReturnType<typeof inputSchema> 87: export type Input = z.infer<InputSchema> 88: export type MessageRouting = { 89: sender: string 90: senderColor?: string 91: target: string 92: targetColor?: string 93: summary?: string 94: content?: string 95: } 96: export type MessageOutput = { 97: success: boolean 98: message: string 99: routing?: MessageRouting 100: } 101: export type BroadcastOutput = { 102: success: boolean 103: message: string 104: recipients: string[] 105: routing?: MessageRouting 106: } 107: export type RequestOutput = { 108: success: boolean 109: message: string 110: request_id: string 111: target: string 112: } 113: export type ResponseOutput = { 114: success: boolean 115: message: string 116: request_id?: string 117: } 118: export type SendMessageToolOutput = 119: | MessageOutput 120: | BroadcastOutput 121: | RequestOutput 122: | ResponseOutput 123: function findTeammateColor( 124: appState: { 125: teamContext?: { teammates: { [id: string]: { color?: string } } } 126: }, 127: name: string, 128: ): string | undefined { 129: const teammates = appState.teamContext?.teammates 130: if (!teammates) return undefined 131: for (const teammate of Object.values(teammates)) { 132: if ('name' in teammate && (teammate as { name: string }).name === name) { 133: return teammate.color 134: } 135: } 136: return undefined 137: } 138: async function handleMessage( 139: recipientName: string, 140: content: string, 141: summary: string | undefined, 142: context: ToolUseContext, 143: ): Promise<{ data: MessageOutput }> { 144: const appState = context.getAppState() 145: const teamName = getTeamName(appState.teamContext) 146: const senderName = 147: getAgentName() || (isTeammate() ? 'teammate' : TEAM_LEAD_NAME) 148: const senderColor = getTeammateColor() 149: await writeToMailbox( 150: recipientName, 151: { 152: from: senderName, 153: text: content, 154: summary, 155: timestamp: new Date().toISOString(), 156: color: senderColor, 157: }, 158: teamName, 159: ) 160: const recipientColor = findTeammateColor(appState, recipientName) 161: return { 162: data: { 163: success: true, 164: message: `Message sent to ${recipientName}'s inbox`, 165: routing: { 166: sender: senderName, 167: senderColor, 168: target: `@${recipientName}`, 169: targetColor: recipientColor, 170: summary, 171: content, 172: }, 173: }, 174: } 175: } 176: async function handleBroadcast( 177: content: string, 178: summary: string | undefined, 179: context: ToolUseContext, 180: ): Promise<{ data: BroadcastOutput }> { 181: const appState = context.getAppState() 182: const teamName = getTeamName(appState.teamContext) 183: if (!teamName) { 184: throw new Error( 185: 'Not in a team context. Create a team with Teammate spawnTeam first, or set CLAUDE_CODE_TEAM_NAME.', 186: ) 187: } 188: const teamFile = await readTeamFileAsync(teamName) 189: if (!teamFile) { 190: throw new Error(`Team "${teamName}" does not exist`) 191: } 192: const senderName = 193: getAgentName() || (isTeammate() ? 'teammate' : TEAM_LEAD_NAME) 194: if (!senderName) { 195: throw new Error( 196: 'Cannot broadcast: sender name is required. Set CLAUDE_CODE_AGENT_NAME.', 197: ) 198: } 199: const senderColor = getTeammateColor() 200: const recipients: string[] = [] 201: for (const member of teamFile.members) { 202: if (member.name.toLowerCase() === senderName.toLowerCase()) { 203: continue 204: } 205: recipients.push(member.name) 206: } 207: if (recipients.length === 0) { 208: return { 209: data: { 210: success: true, 211: message: 'No teammates to broadcast to (you are the only team member)', 212: recipients: [], 213: }, 214: } 215: } 216: for (const recipientName of recipients) { 217: await writeToMailbox( 218: recipientName, 219: { 220: from: senderName, 221: text: content, 222: summary, 223: timestamp: new Date().toISOString(), 224: color: senderColor, 225: }, 226: teamName, 227: ) 228: } 229: return { 230: data: { 231: success: true, 232: message: `Message broadcast to ${recipients.length} teammate(s): ${recipients.join(', ')}`, 233: recipients, 234: routing: { 235: sender: senderName, 236: senderColor, 237: target: '@team', 238: summary, 239: content, 240: }, 241: }, 242: } 243: } 244: async function handleShutdownRequest( 245: targetName: string, 246: reason: string | undefined, 247: context: ToolUseContext, 248: ): Promise<{ data: RequestOutput }> { 249: const appState = context.getAppState() 250: const teamName = getTeamName(appState.teamContext) 251: const senderName = getAgentName() || TEAM_LEAD_NAME 252: const requestId = generateRequestId('shutdown', targetName) 253: const shutdownMessage = createShutdownRequestMessage({ 254: requestId, 255: from: senderName, 256: reason, 257: }) 258: await writeToMailbox( 259: targetName, 260: { 261: from: senderName, 262: text: jsonStringify(shutdownMessage), 263: timestamp: new Date().toISOString(), 264: color: getTeammateColor(), 265: }, 266: teamName, 267: ) 268: return { 269: data: { 270: success: true, 271: message: `Shutdown request sent to ${targetName}. Request ID: ${requestId}`, 272: request_id: requestId, 273: target: targetName, 274: }, 275: } 276: } 277: async function handleShutdownApproval( 278: requestId: string, 279: context: ToolUseContext, 280: ): Promise<{ data: ResponseOutput }> { 281: const teamName = getTeamName() 282: const agentId = getAgentId() 283: const agentName = getAgentName() || 'teammate' 284: logForDebugging( 285: `[SendMessageTool] handleShutdownApproval: teamName=${teamName}, agentId=${agentId}, agentName=${agentName}`, 286: ) 287: let ownPaneId: string | undefined 288: let ownBackendType: BackendType | undefined 289: if (teamName) { 290: const teamFile = await readTeamFileAsync(teamName) 291: if (teamFile && agentId) { 292: const selfMember = teamFile.members.find(m => m.agentId === agentId) 293: if (selfMember) { 294: ownPaneId = selfMember.tmuxPaneId 295: ownBackendType = selfMember.backendType 296: } 297: } 298: } 299: const approvedMessage = createShutdownApprovedMessage({ 300: requestId, 301: from: agentName, 302: paneId: ownPaneId, 303: backendType: ownBackendType, 304: }) 305: await writeToMailbox( 306: TEAM_LEAD_NAME, 307: { 308: from: agentName, 309: text: jsonStringify(approvedMessage), 310: timestamp: new Date().toISOString(), 311: color: getTeammateColor(), 312: }, 313: teamName, 314: ) 315: if (ownBackendType === 'in-process') { 316: logForDebugging( 317: `[SendMessageTool] In-process teammate ${agentName} approving shutdown - signaling abort`, 318: ) 319: if (agentId) { 320: const appState = context.getAppState() 321: const task = findTeammateTaskByAgentId(agentId, appState.tasks) 322: if (task?.abortController) { 323: task.abortController.abort() 324: logForDebugging( 325: `[SendMessageTool] Aborted controller for in-process teammate ${agentName}`, 326: ) 327: } else { 328: logForDebugging( 329: `[SendMessageTool] Warning: Could not find task/abortController for ${agentName}`, 330: ) 331: } 332: } 333: } else { 334: if (agentId) { 335: const appState = context.getAppState() 336: const task = findTeammateTaskByAgentId(agentId, appState.tasks) 337: if (task?.abortController) { 338: logForDebugging( 339: `[SendMessageTool] Fallback: Found in-process task for ${agentName} via AppState, aborting`, 340: ) 341: task.abortController.abort() 342: return { 343: data: { 344: success: true, 345: message: `Shutdown approved (fallback path). Agent ${agentName} is now exiting.`, 346: request_id: requestId, 347: }, 348: } 349: } 350: } 351: setImmediate(async () => { 352: await gracefulShutdown(0, 'other') 353: }) 354: } 355: return { 356: data: { 357: success: true, 358: message: `Shutdown approved. Sent confirmation to team-lead. Agent ${agentName} is now exiting.`, 359: request_id: requestId, 360: }, 361: } 362: } 363: async function handleShutdownRejection( 364: requestId: string, 365: reason: string, 366: ): Promise<{ data: ResponseOutput }> { 367: const teamName = getTeamName() 368: const agentName = getAgentName() || 'teammate' 369: const rejectedMessage = createShutdownRejectedMessage({ 370: requestId, 371: from: agentName, 372: reason, 373: }) 374: await writeToMailbox( 375: TEAM_LEAD_NAME, 376: { 377: from: agentName, 378: text: jsonStringify(rejectedMessage), 379: timestamp: new Date().toISOString(), 380: color: getTeammateColor(), 381: }, 382: teamName, 383: ) 384: return { 385: data: { 386: success: true, 387: message: `Shutdown rejected. Reason: "${reason}". Continuing to work.`, 388: request_id: requestId, 389: }, 390: } 391: } 392: async function handlePlanApproval( 393: recipientName: string, 394: requestId: string, 395: context: ToolUseContext, 396: ): Promise<{ data: ResponseOutput }> { 397: const appState = context.getAppState() 398: const teamName = appState.teamContext?.teamName 399: if (!isTeamLead(appState.teamContext)) { 400: throw new Error( 401: 'Only the team lead can approve plans. Teammates cannot approve their own or other plans.', 402: ) 403: } 404: const leaderMode = appState.toolPermissionContext.mode 405: const modeToInherit = leaderMode === 'plan' ? 'default' : leaderMode 406: const approvalResponse = { 407: type: 'plan_approval_response', 408: requestId, 409: approved: true, 410: timestamp: new Date().toISOString(), 411: permissionMode: modeToInherit, 412: } 413: await writeToMailbox( 414: recipientName, 415: { 416: from: TEAM_LEAD_NAME, 417: text: jsonStringify(approvalResponse), 418: timestamp: new Date().toISOString(), 419: }, 420: teamName, 421: ) 422: return { 423: data: { 424: success: true, 425: message: `Plan approved for ${recipientName}. They will receive the approval and can proceed with implementation.`, 426: request_id: requestId, 427: }, 428: } 429: } 430: async function handlePlanRejection( 431: recipientName: string, 432: requestId: string, 433: feedback: string, 434: context: ToolUseContext, 435: ): Promise<{ data: ResponseOutput }> { 436: const appState = context.getAppState() 437: const teamName = appState.teamContext?.teamName 438: if (!isTeamLead(appState.teamContext)) { 439: throw new Error( 440: 'Only the team lead can reject plans. Teammates cannot reject their own or other plans.', 441: ) 442: } 443: const rejectionResponse = { 444: type: 'plan_approval_response', 445: requestId, 446: approved: false, 447: feedback, 448: timestamp: new Date().toISOString(), 449: } 450: await writeToMailbox( 451: recipientName, 452: { 453: from: TEAM_LEAD_NAME, 454: text: jsonStringify(rejectionResponse), 455: timestamp: new Date().toISOString(), 456: }, 457: teamName, 458: ) 459: return { 460: data: { 461: success: true, 462: message: `Plan rejected for ${recipientName} with feedback: "${feedback}"`, 463: request_id: requestId, 464: }, 465: } 466: } 467: export const SendMessageTool: Tool<InputSchema, SendMessageToolOutput> = 468: buildTool({ 469: name: SEND_MESSAGE_TOOL_NAME, 470: searchHint: 'send messages to agent teammates (swarm protocol)', 471: maxResultSizeChars: 100_000, 472: userFacingName() { 473: return 'SendMessage' 474: }, 475: get inputSchema(): InputSchema { 476: return inputSchema() 477: }, 478: shouldDefer: true, 479: isEnabled() { 480: return isAgentSwarmsEnabled() 481: }, 482: isReadOnly(input) { 483: return typeof input.message === 'string' 484: }, 485: backfillObservableInput(input) { 486: if ('type' in input) return 487: if (typeof input.to !== 'string') return 488: if (input.to === '*') { 489: input.type = 'broadcast' 490: if (typeof input.message === 'string') input.content = input.message 491: } else if (typeof input.message === 'string') { 492: input.type = 'message' 493: input.recipient = input.to 494: input.content = input.message 495: } else if (typeof input.message === 'object' && input.message !== null) { 496: const msg = input.message as { 497: type?: string 498: request_id?: string 499: approve?: boolean 500: reason?: string 501: feedback?: string 502: } 503: input.type = msg.type 504: input.recipient = input.to 505: if (msg.request_id !== undefined) input.request_id = msg.request_id 506: if (msg.approve !== undefined) input.approve = msg.approve 507: const content = msg.reason ?? msg.feedback 508: if (content !== undefined) input.content = content 509: } 510: }, 511: toAutoClassifierInput(input) { 512: if (typeof input.message === 'string') { 513: return `to ${input.to}: ${input.message}` 514: } 515: switch (input.message.type) { 516: case 'shutdown_request': 517: return `shutdown_request to ${input.to}` 518: case 'shutdown_response': 519: return `shutdown_response ${input.message.approve ? 'approve' : 'reject'} ${input.message.request_id}` 520: case 'plan_approval_response': 521: return `plan_approval ${input.message.approve ? 'approve' : 'reject'} to ${input.to}` 522: } 523: }, 524: async checkPermissions(input, _context) { 525: if (feature('UDS_INBOX') && parseAddress(input.to).scheme === 'bridge') { 526: return { 527: behavior: 'ask' as const, 528: message: `Send a message to Remote Control session ${input.to}? It arrives as a user prompt on the receiving Claude (possibly another machine) via Anthropic's servers.`, 529: decisionReason: { 530: type: 'safetyCheck', 531: reason: 532: 'Cross-machine bridge message requires explicit user consent', 533: classifierApprovable: false, 534: }, 535: } 536: } 537: return { behavior: 'allow' as const, updatedInput: input } 538: }, 539: async validateInput(input, _context) { 540: if (input.to.trim().length === 0) { 541: return { 542: result: false, 543: message: 'to must not be empty', 544: errorCode: 9, 545: } 546: } 547: const addr = parseAddress(input.to) 548: if ( 549: (addr.scheme === 'bridge' || addr.scheme === 'uds') && 550: addr.target.trim().length === 0 551: ) { 552: return { 553: result: false, 554: message: 'address target must not be empty', 555: errorCode: 9, 556: } 557: } 558: if (input.to.includes('@')) { 559: return { 560: result: false, 561: message: 562: 'to must be a bare teammate name or "*" — there is only one team per session', 563: errorCode: 9, 564: } 565: } 566: if (feature('UDS_INBOX') && parseAddress(input.to).scheme === 'bridge') { 567: if (typeof input.message !== 'string') { 568: return { 569: result: false, 570: message: 571: 'structured messages cannot be sent cross-session — only plain text', 572: errorCode: 9, 573: } 574: } 575: if (!getReplBridgeHandle() || !isReplBridgeActive()) { 576: return { 577: result: false, 578: message: 579: 'Remote Control is not connected — cannot send to a bridge: target. Reconnect with /remote-control first.', 580: errorCode: 9, 581: } 582: } 583: return { result: true } 584: } 585: if ( 586: feature('UDS_INBOX') && 587: parseAddress(input.to).scheme === 'uds' && 588: typeof input.message === 'string' 589: ) { 590: return { result: true } 591: } 592: if (typeof input.message === 'string') { 593: if (!input.summary || input.summary.trim().length === 0) { 594: return { 595: result: false, 596: message: 'summary is required when message is a string', 597: errorCode: 9, 598: } 599: } 600: return { result: true } 601: } 602: if (input.to === '*') { 603: return { 604: result: false, 605: message: 'structured messages cannot be broadcast (to: "*")', 606: errorCode: 9, 607: } 608: } 609: if (feature('UDS_INBOX') && parseAddress(input.to).scheme !== 'other') { 610: return { 611: result: false, 612: message: 613: 'structured messages cannot be sent cross-session — only plain text', 614: errorCode: 9, 615: } 616: } 617: if ( 618: input.message.type === 'shutdown_response' && 619: input.to !== TEAM_LEAD_NAME 620: ) { 621: return { 622: result: false, 623: message: `shutdown_response must be sent to "${TEAM_LEAD_NAME}"`, 624: errorCode: 9, 625: } 626: } 627: if ( 628: input.message.type === 'shutdown_response' && 629: !input.message.approve && 630: (!input.message.reason || input.message.reason.trim().length === 0) 631: ) { 632: return { 633: result: false, 634: message: 'reason is required when rejecting a shutdown request', 635: errorCode: 9, 636: } 637: } 638: return { result: true } 639: }, 640: async description() { 641: return DESCRIPTION 642: }, 643: async prompt() { 644: return getPrompt() 645: }, 646: mapToolResultToToolResultBlockParam(data, toolUseID) { 647: return { 648: tool_use_id: toolUseID, 649: type: 'tool_result' as const, 650: content: [ 651: { 652: type: 'text' as const, 653: text: jsonStringify(data), 654: }, 655: ], 656: } 657: }, 658: async call(input, context, canUseTool, assistantMessage) { 659: if (feature('UDS_INBOX') && typeof input.message === 'string') { 660: const addr = parseAddress(input.to) 661: if (addr.scheme === 'bridge') { 662: if (!getReplBridgeHandle() || !isReplBridgeActive()) { 663: return { 664: data: { 665: success: false, 666: message: `Remote Control disconnected before send — cannot deliver to ${input.to}`, 667: }, 668: } 669: } 670: const { postInterClaudeMessage } = 671: require('../../bridge/peerSessions.js') as typeof import('../../bridge/peerSessions.js') 672: const result = await postInterClaudeMessage( 673: addr.target, 674: input.message, 675: ) 676: const preview = input.summary || truncate(input.message, 50) 677: return { 678: data: { 679: success: result.ok, 680: message: result.ok 681: ? `“${preview}” → ${input.to}` 682: : `Failed to send to ${input.to}: ${result.error ?? 'unknown'}`, 683: }, 684: } 685: } 686: if (addr.scheme === 'uds') { 687: const { sendToUdsSocket } = 688: require('../../utils/udsClient.js') as typeof import('../../utils/udsClient.js') 689: try { 690: await sendToUdsSocket(addr.target, input.message) 691: const preview = input.summary || truncate(input.message, 50) 692: return { 693: data: { 694: success: true, 695: message: `“${preview}” → ${input.to}`, 696: }, 697: } 698: } catch (e) { 699: return { 700: data: { 701: success: false, 702: message: `Failed to send to ${input.to}: ${errorMessage(e)}`, 703: }, 704: } 705: } 706: } 707: } 708: if (typeof input.message === 'string' && input.to !== '*') { 709: const appState = context.getAppState() 710: const registered = appState.agentNameRegistry.get(input.to) 711: const agentId = registered ?? toAgentId(input.to) 712: if (agentId) { 713: const task = appState.tasks[agentId] 714: if (isLocalAgentTask(task) && !isMainSessionTask(task)) { 715: if (task.status === 'running') { 716: queuePendingMessage( 717: agentId, 718: input.message, 719: context.setAppStateForTasks ?? context.setAppState, 720: ) 721: return { 722: data: { 723: success: true, 724: message: `Message queued for delivery to ${input.to} at its next tool round.`, 725: }, 726: } 727: } 728: try { 729: const result = await resumeAgentBackground({ 730: agentId, 731: prompt: input.message, 732: toolUseContext: context, 733: canUseTool, 734: invokingRequestId: assistantMessage?.requestId, 735: }) 736: return { 737: data: { 738: success: true, 739: message: `Agent "${input.to}" was stopped (${task.status}); resumed it in the background with your message. You'll be notified when it finishes. Output: ${result.outputFile}`, 740: }, 741: } 742: } catch (e) { 743: return { 744: data: { 745: success: false, 746: message: `Agent "${input.to}" is stopped (${task.status}) and could not be resumed: ${errorMessage(e)}`, 747: }, 748: } 749: } 750: } else { 751: try { 752: const result = await resumeAgentBackground({ 753: agentId, 754: prompt: input.message, 755: toolUseContext: context, 756: canUseTool, 757: invokingRequestId: assistantMessage?.requestId, 758: }) 759: return { 760: data: { 761: success: true, 762: message: `Agent "${input.to}" had no active task; resumed from transcript in the background with your message. You'll be notified when it finishes. Output: ${result.outputFile}`, 763: }, 764: } 765: } catch (e) { 766: return { 767: data: { 768: success: false, 769: message: `Agent "${input.to}" is registered but has no transcript to resume. It may have been cleaned up. (${errorMessage(e)})`, 770: }, 771: } 772: } 773: } 774: } 775: } 776: if (typeof input.message === 'string') { 777: if (input.to === '*') { 778: return handleBroadcast(input.message, input.summary, context) 779: } 780: return handleMessage(input.to, input.message, input.summary, context) 781: } 782: if (input.to === '*') { 783: throw new Error('structured messages cannot be broadcast') 784: } 785: switch (input.message.type) { 786: case 'shutdown_request': 787: return handleShutdownRequest(input.to, input.message.reason, context) 788: case 'shutdown_response': 789: if (input.message.approve) { 790: return handleShutdownApproval(input.message.request_id, context) 791: } 792: return handleShutdownRejection( 793: input.message.request_id, 794: input.message.reason!, 795: ) 796: case 'plan_approval_response': 797: if (input.message.approve) { 798: return handlePlanApproval( 799: input.to, 800: input.message.request_id, 801: context, 802: ) 803: } 804: return handlePlanRejection( 805: input.to, 806: input.message.request_id, 807: input.message.feedback ?? 'Plan needs revision', 808: context, 809: ) 810: } 811: }, 812: renderToolUseMessage, 813: renderToolResultMessage, 814: } satisfies ToolDef<InputSchema, SendMessageToolOutput>)

File: src/tools/SendMessageTool/UI.tsx

typescript 1: import React from 'react'; 2: import { MessageResponse } from '../../components/MessageResponse.js'; 3: import { Text } from '../../ink.js'; 4: import { jsonParse } from '../../utils/slowOperations.js'; 5: import type { Input, SendMessageToolOutput } from './SendMessageTool.js'; 6: export function renderToolUseMessage(input: Partial<Input>): React.ReactNode { 7: if (typeof input.message !== 'object' || input.message === null) { 8: return null; 9: } 10: if (input.message.type === 'plan_approval_response') { 11: return input.message.approve ? `approve plan from: ${input.to}` : `reject plan from: ${input.to}`; 12: } 13: return null; 14: } 15: export function renderToolResultMessage(content: SendMessageToolOutput | string, _progressMessages: unknown, { 16: verbose 17: }: { 18: verbose: boolean; 19: }): React.ReactNode { 20: const result: SendMessageToolOutput = typeof content === 'string' ? jsonParse(content) : content; 21: if ('routing' in result && result.routing) { 22: return null; 23: } 24: if ('request_id' in result && 'target' in result) { 25: return null; 26: } 27: return <MessageResponse> 28: <Text dimColor>{result.message}</Text> 29: </MessageResponse>; 30: }

File: src/tools/shared/gitOperationTracking.ts

typescript 1: import { getCommitCounter, getPrCounter } 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: function gitCmdRe(subcmd: string, suffix = ''): RegExp { 7: return new RegExp( 8: `\\bgit(?:\\s+-[cC]\\s+\\S+|\\s+--\\S+=\\S+)*\\s+${subcmd}\\b${suffix}`, 9: ) 10: } 11: const GIT_COMMIT_RE = gitCmdRe('commit') 12: const GIT_PUSH_RE = gitCmdRe('push') 13: const GIT_CHERRY_PICK_RE = gitCmdRe('cherry-pick') 14: const GIT_MERGE_RE = gitCmdRe('merge', '(?!-)') 15: const GIT_REBASE_RE = gitCmdRe('rebase') 16: export type CommitKind = 'committed' | 'amended' | 'cherry-picked' 17: export type BranchAction = 'merged' | 'rebased' 18: export type PrAction = 19: | 'created' 20: | 'edited' 21: | 'merged' 22: | 'commented' 23: | 'closed' 24: | 'ready' 25: const GH_PR_ACTIONS: readonly { re: RegExp; action: PrAction; op: string }[] = [ 26: { re: /\bgh\s+pr\s+create\b/, action: 'created', op: 'pr_create' }, 27: { re: /\bgh\s+pr\s+edit\b/, action: 'edited', op: 'pr_edit' }, 28: { re: /\bgh\s+pr\s+merge\b/, action: 'merged', op: 'pr_merge' }, 29: { re: /\bgh\s+pr\s+comment\b/, action: 'commented', op: 'pr_comment' }, 30: { re: /\bgh\s+pr\s+close\b/, action: 'closed', op: 'pr_close' }, 31: { re: /\bgh\s+pr\s+ready\b/, action: 'ready', op: 'pr_ready' }, 32: ] 33: function parsePrUrl( 34: url: string, 35: ): { prNumber: number; prUrl: string; prRepository: string } | null { 36: const match = url.match(/https:\/\/github\.com\/([^/]+\/[^/]+)\/pull\/(\d+)/) 37: if (match?.[1] && match?.[2]) { 38: return { 39: prNumber: parseInt(match[2], 10), 40: prUrl: url, 41: prRepository: match[1], 42: } 43: } 44: return null 45: } 46: function findPrInStdout(stdout: string): ReturnType<typeof parsePrUrl> { 47: const m = stdout.match(/https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/pull\/\d+/) 48: return m ? parsePrUrl(m[0]) : null 49: } 50: export function parseGitCommitId(stdout: string): string | undefined { 51: const match = stdout.match(/\[[\w./-]+(?: \(root-commit\))? ([0-9a-f]+)\]/) 52: return match?.[1] 53: } 54: function parseGitPushBranch(output: string): string | undefined { 55: const match = output.match( 56: /^\s*[+\-*!= ]?\s*(?:\[new branch\]|\S+\.\.+\S+)\s+\S+\s*->\s*(\S+)/m, 57: ) 58: return match?.[1] 59: } 60: function parsePrNumberFromText(stdout: string): number | undefined { 61: const match = stdout.match(/[Pp]ull request (?:\S+#)?#?(\d+)/) 62: return match?.[1] ? parseInt(match[1], 10) : undefined 63: } 64: function parseRefFromCommand( 65: command: string, 66: verb: string, 67: ): string | undefined { 68: const after = command.split(gitCmdRe(verb))[1] 69: if (!after) return undefined 70: for (const t of after.trim().split(/\s+/)) { 71: if (/^[&|;><]/.test(t)) break 72: if (t.startsWith('-')) continue 73: return t 74: } 75: return undefined 76: } 77: export function detectGitOperation( 78: command: string, 79: output: string, 80: ): { 81: commit?: { sha: string; kind: CommitKind } 82: push?: { branch: string } 83: branch?: { ref: string; action: BranchAction } 84: pr?: { number: number; url?: string; action: PrAction } 85: } { 86: const result: ReturnType<typeof detectGitOperation> = {} 87: const isCherryPick = GIT_CHERRY_PICK_RE.test(command) 88: if (GIT_COMMIT_RE.test(command) || isCherryPick) { 89: const sha = parseGitCommitId(output) 90: if (sha) { 91: result.commit = { 92: sha: sha.slice(0, 6), 93: kind: isCherryPick 94: ? 'cherry-picked' 95: : /--amend\b/.test(command) 96: ? 'amended' 97: : 'committed', 98: } 99: } 100: } 101: if (GIT_PUSH_RE.test(command)) { 102: const branch = parseGitPushBranch(output) 103: if (branch) result.push = { branch } 104: } 105: if ( 106: GIT_MERGE_RE.test(command) && 107: /(Fast-forward|Merge made by)/.test(output) 108: ) { 109: const ref = parseRefFromCommand(command, 'merge') 110: if (ref) result.branch = { ref, action: 'merged' } 111: } 112: if (GIT_REBASE_RE.test(command) && /Successfully rebased/.test(output)) { 113: const ref = parseRefFromCommand(command, 'rebase') 114: if (ref) result.branch = { ref, action: 'rebased' } 115: } 116: const prAction = GH_PR_ACTIONS.find(a => a.re.test(command))?.action 117: if (prAction) { 118: const pr = findPrInStdout(output) 119: if (pr) { 120: result.pr = { number: pr.prNumber, url: pr.prUrl, action: prAction } 121: } else { 122: const num = parsePrNumberFromText(output) 123: if (num) result.pr = { number: num, action: prAction } 124: } 125: } 126: return result 127: } 128: export function trackGitOperations( 129: command: string, 130: exitCode: number, 131: stdout?: string, 132: ): void { 133: const success = exitCode === 0 134: if (!success) { 135: return 136: } 137: if (GIT_COMMIT_RE.test(command)) { 138: logEvent('tengu_git_operation', { 139: operation: 140: 'commit' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 141: }) 142: if (command.match(/--amend\b/)) { 143: logEvent('tengu_git_operation', { 144: operation: 145: 'commit_amend' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 146: }) 147: } 148: getCommitCounter()?.add(1) 149: } 150: if (GIT_PUSH_RE.test(command)) { 151: logEvent('tengu_git_operation', { 152: operation: 153: 'push' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 154: }) 155: } 156: const prHit = GH_PR_ACTIONS.find(a => a.re.test(command)) 157: if (prHit) { 158: logEvent('tengu_git_operation', { 159: operation: 160: prHit.op as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 161: }) 162: } 163: if (prHit?.action === 'created') { 164: getPrCounter()?.add(1) 165: if (stdout) { 166: const prInfo = findPrInStdout(stdout) 167: if (prInfo) { 168: void import('../../utils/sessionStorage.js').then( 169: ({ linkSessionToPR }) => { 170: void import('../../bootstrap/state.js').then(({ getSessionId }) => { 171: const sessionId = getSessionId() 172: if (sessionId) { 173: void linkSessionToPR( 174: sessionId as `${string}-${string}-${string}-${string}-${string}`, 175: prInfo.prNumber, 176: prInfo.prUrl, 177: prInfo.prRepository, 178: ) 179: } 180: }) 181: }, 182: ) 183: } 184: } 185: } 186: if (command.match(/\bglab\s+mr\s+create\b/)) { 187: logEvent('tengu_git_operation', { 188: operation: 189: 'pr_create' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 190: }) 191: getPrCounter()?.add(1) 192: } 193: const isCurlPost = 194: command.match(/\bcurl\b/) && 195: (command.match(/-X\s*POST\b/i) || 196: command.match(/--request\s*=?\s*POST\b/i) || 197: command.match(/\s-d\s/)) 198: const isPrEndpoint = command.match( 199: /https?:\/\/[^\s'"]*\/(pulls|pull-requests|merge[-_]requests)(?!\/\d)/i, 200: ) 201: if (isCurlPost && isPrEndpoint) { 202: logEvent('tengu_git_operation', { 203: operation: 204: 'pr_create' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 205: }) 206: getPrCounter()?.add(1) 207: } 208: }

File: src/tools/shared/spawnMultiAgent.ts

typescript 1: import React from 'react' 2: import { 3: getChromeFlagOverride, 4: getFlagSettingsPath, 5: getInlinePlugins, 6: getMainLoopModelOverride, 7: getSessionBypassPermissionsMode, 8: getSessionId, 9: } from '../../bootstrap/state.js' 10: import type { AppState } from '../../state/AppState.js' 11: import { createTaskStateBase, generateTaskId } from '../../Task.js' 12: import type { ToolUseContext } from '../../Tool.js' 13: import type { InProcessTeammateTaskState } from '../../tasks/InProcessTeammateTask/types.js' 14: import { formatAgentId } from '../../utils/agentId.js' 15: import { quote } from '../../utils/bash/shellQuote.js' 16: import { isInBundledMode } from '../../utils/bundledMode.js' 17: import { getGlobalConfig } from '../../utils/config.js' 18: import { getCwd } from '../../utils/cwd.js' 19: import { logForDebugging } from '../../utils/debug.js' 20: import { errorMessage } from '../../utils/errors.js' 21: import { execFileNoThrow } from '../../utils/execFileNoThrow.js' 22: import { parseUserSpecifiedModel } from '../../utils/model/model.js' 23: import type { PermissionMode } from '../../utils/permissions/PermissionMode.js' 24: import { isTmuxAvailable } from '../../utils/swarm/backends/detection.js' 25: import { 26: detectAndGetBackend, 27: getBackendByType, 28: isInProcessEnabled, 29: markInProcessFallback, 30: resetBackendDetection, 31: } from '../../utils/swarm/backends/registry.js' 32: import { getTeammateModeFromSnapshot } from '../../utils/swarm/backends/teammateModeSnapshot.js' 33: import type { BackendType } from '../../utils/swarm/backends/types.js' 34: import { isPaneBackend } from '../../utils/swarm/backends/types.js' 35: import { 36: SWARM_SESSION_NAME, 37: TEAM_LEAD_NAME, 38: TEAMMATE_COMMAND_ENV_VAR, 39: TMUX_COMMAND, 40: } from '../../utils/swarm/constants.js' 41: import { It2SetupPrompt } from '../../utils/swarm/It2SetupPrompt.js' 42: import { startInProcessTeammate } from '../../utils/swarm/inProcessRunner.js' 43: import { 44: type InProcessSpawnConfig, 45: spawnInProcessTeammate, 46: } from '../../utils/swarm/spawnInProcess.js' 47: import { buildInheritedEnvVars } from '../../utils/swarm/spawnUtils.js' 48: import { 49: readTeamFileAsync, 50: sanitizeAgentName, 51: sanitizeName, 52: writeTeamFileAsync, 53: } from '../../utils/swarm/teamHelpers.js' 54: import { 55: assignTeammateColor, 56: createTeammatePaneInSwarmView, 57: enablePaneBorderStatus, 58: isInsideTmux, 59: sendCommandToPane, 60: } from '../../utils/swarm/teammateLayoutManager.js' 61: import { getHardcodedTeammateModelFallback } from '../../utils/swarm/teammateModel.js' 62: import { registerTask } from '../../utils/task/framework.js' 63: import { writeToMailbox } from '../../utils/teammateMailbox.js' 64: import type { CustomAgentDefinition } from '../AgentTool/loadAgentsDir.js' 65: import { isCustomAgent } from '../AgentTool/loadAgentsDir.js' 66: function getDefaultTeammateModel(leaderModel: string | null): string { 67: const configured = getGlobalConfig().teammateDefaultModel 68: if (configured === null) { 69: return leaderModel ?? getHardcodedTeammateModelFallback() 70: } 71: if (configured !== undefined) { 72: return parseUserSpecifiedModel(configured) 73: } 74: return getHardcodedTeammateModelFallback() 75: } 76: export function resolveTeammateModel( 77: inputModel: string | undefined, 78: leaderModel: string | null, 79: ): string { 80: if (inputModel === 'inherit') { 81: return leaderModel ?? getDefaultTeammateModel(leaderModel) 82: } 83: return inputModel ?? getDefaultTeammateModel(leaderModel) 84: } 85: export type SpawnOutput = { 86: teammate_id: string 87: agent_id: string 88: agent_type?: string 89: model?: string 90: name: string 91: color?: string 92: tmux_session_name: string 93: tmux_window_name: string 94: tmux_pane_id: string 95: team_name?: string 96: is_splitpane?: boolean 97: plan_mode_required?: boolean 98: } 99: export type SpawnTeammateConfig = { 100: name: string 101: prompt: string 102: team_name?: string 103: cwd?: string 104: use_splitpane?: boolean 105: plan_mode_required?: boolean 106: model?: string 107: agent_type?: string 108: description?: string 109: invokingRequestId?: string 110: } 111: type SpawnInput = { 112: name: string 113: prompt: string 114: team_name?: string 115: cwd?: string 116: use_splitpane?: boolean 117: plan_mode_required?: boolean 118: model?: string 119: agent_type?: string 120: description?: string 121: invokingRequestId?: string 122: } 123: async function hasSession(sessionName: string): Promise<boolean> { 124: const result = await execFileNoThrow(TMUX_COMMAND, [ 125: 'has-session', 126: '-t', 127: sessionName, 128: ]) 129: return result.code === 0 130: } 131: async function ensureSession(sessionName: string): Promise<void> { 132: const exists = await hasSession(sessionName) 133: if (!exists) { 134: const result = await execFileNoThrow(TMUX_COMMAND, [ 135: 'new-session', 136: '-d', 137: '-s', 138: sessionName, 139: ]) 140: if (result.code !== 0) { 141: throw new Error( 142: `Failed to create tmux session '${sessionName}': ${result.stderr || 'Unknown error'}`, 143: ) 144: } 145: } 146: } 147: function getTeammateCommand(): string { 148: if (process.env[TEAMMATE_COMMAND_ENV_VAR]) { 149: return process.env[TEAMMATE_COMMAND_ENV_VAR] 150: } 151: return isInBundledMode() ? process.execPath : process.argv[1]! 152: } 153: function buildInheritedCliFlags(options?: { 154: planModeRequired?: boolean 155: permissionMode?: PermissionMode 156: }): string { 157: const flags: string[] = [] 158: const { planModeRequired, permissionMode } = options || {} 159: if (planModeRequired) { 160: } else if ( 161: permissionMode === 'bypassPermissions' || 162: getSessionBypassPermissionsMode() 163: ) { 164: flags.push('--dangerously-skip-permissions') 165: } else if (permissionMode === 'acceptEdits') { 166: flags.push('--permission-mode acceptEdits') 167: } else if (permissionMode === 'auto') { 168: flags.push('--permission-mode auto') 169: } 170: const modelOverride = getMainLoopModelOverride() 171: if (modelOverride) { 172: flags.push(`--model ${quote([modelOverride])}`) 173: } 174: const settingsPath = getFlagSettingsPath() 175: if (settingsPath) { 176: flags.push(`--settings ${quote([settingsPath])}`) 177: } 178: const inlinePlugins = getInlinePlugins() 179: for (const pluginDir of inlinePlugins) { 180: flags.push(`--plugin-dir ${quote([pluginDir])}`) 181: } 182: const chromeFlagOverride = getChromeFlagOverride() 183: if (chromeFlagOverride === true) { 184: flags.push('--chrome') 185: } else if (chromeFlagOverride === false) { 186: flags.push('--no-chrome') 187: } 188: return flags.join(' ') 189: } 190: export async function generateUniqueTeammateName( 191: baseName: string, 192: teamName: string | undefined, 193: ): Promise<string> { 194: if (!teamName) { 195: return baseName 196: } 197: const teamFile = await readTeamFileAsync(teamName) 198: if (!teamFile) { 199: return baseName 200: } 201: const existingNames = new Set(teamFile.members.map(m => m.name.toLowerCase())) 202: if (!existingNames.has(baseName.toLowerCase())) { 203: return baseName 204: } 205: let suffix = 2 206: while (existingNames.has(`${baseName}-${suffix}`.toLowerCase())) { 207: suffix++ 208: } 209: return `${baseName}-${suffix}` 210: } 211: async function handleSpawnSplitPane( 212: input: SpawnInput, 213: context: ToolUseContext, 214: ): Promise<{ data: SpawnOutput }> { 215: const { setAppState, getAppState } = context 216: const { name, prompt, agent_type, cwd, plan_mode_required } = input 217: const model = resolveTeammateModel(input.model, getAppState().mainLoopModel) 218: if (!name || !prompt) { 219: throw new Error('name and prompt are required for spawn operation') 220: } 221: const appState = getAppState() 222: const teamName = input.team_name || appState.teamContext?.teamName 223: if (!teamName) { 224: throw new Error( 225: 'team_name is required for spawn operation. Either provide team_name in input or call spawnTeam first to establish team context.', 226: ) 227: } 228: const uniqueName = await generateUniqueTeammateName(name, teamName) 229: const sanitizedName = sanitizeAgentName(uniqueName) 230: const teammateId = formatAgentId(sanitizedName, teamName) 231: const workingDir = cwd || getCwd() 232: let detectionResult = await detectAndGetBackend() 233: if (detectionResult.needsIt2Setup && context.setToolJSX) { 234: const tmuxAvailable = await isTmuxAvailable() 235: const setupResult = await new Promise< 236: 'installed' | 'use-tmux' | 'cancelled' 237: >(resolve => { 238: context.setToolJSX!({ 239: jsx: React.createElement(It2SetupPrompt, { 240: onDone: resolve, 241: tmuxAvailable, 242: }), 243: shouldHidePromptInput: true, 244: }) 245: }) 246: context.setToolJSX(null) 247: if (setupResult === 'cancelled') { 248: throw new Error('Teammate spawn cancelled - iTerm2 setup required') 249: } 250: if (setupResult === 'installed' || setupResult === 'use-tmux') { 251: resetBackendDetection() 252: detectionResult = await detectAndGetBackend() 253: } 254: } 255: const insideTmux = await isInsideTmux() 256: const teammateColor = assignTeammateColor(teammateId) 257: const { paneId, isFirstTeammate } = await createTeammatePaneInSwarmView( 258: sanitizedName, 259: teammateColor, 260: ) 261: if (isFirstTeammate && insideTmux) { 262: await enablePaneBorderStatus() 263: } 264: const binaryPath = getTeammateCommand() 265: const teammateArgs = [ 266: `--agent-id ${quote([teammateId])}`, 267: `--agent-name ${quote([sanitizedName])}`, 268: `--team-name ${quote([teamName])}`, 269: `--agent-color ${quote([teammateColor])}`, 270: `--parent-session-id ${quote([getSessionId()])}`, 271: plan_mode_required ? '--plan-mode-required' : '', 272: agent_type ? `--agent-type ${quote([agent_type])}` : '', 273: ] 274: .filter(Boolean) 275: .join(' ') 276: // Build CLI flags to propagate to teammate 277: // Pass plan_mode_required to prevent inheriting bypass permissions 278: let inheritedFlags = buildInheritedCliFlags({ 279: planModeRequired: plan_mode_required, 280: permissionMode: appState.toolPermissionContext.mode, 281: }) 282: // If teammate has a custom model, add --model flag (or replace inherited one) 283: if (model) { 284: // Remove any inherited --model flag first 285: inheritedFlags = inheritedFlags 286: .split(' ') 287: .filter((flag, i, arr) => flag !== '--model' && arr[i - 1] !== '--model') 288: .join(' ') 289: inheritedFlags = inheritedFlags 290: ? `${inheritedFlags} --model ${quote([model])}` 291: : `--model ${quote([model])}` 292: } 293: const flagsStr = inheritedFlags ? ` ${inheritedFlags}` : '' 294: // Propagate env vars that teammates need but may not inherit from tmux split-window shells. 295: // Includes CLAUDECODE, CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS, and API provider vars. 296: const envStr = buildInheritedEnvVars() 297: const spawnCommand = `cd ${quote([workingDir])} && env ${envStr} ${quote([binaryPath])} ${teammateArgs}${flagsStr}` 298: // Send the command to the new pane 299: // Use swarm socket when running outside tmux (external swarm session) 300: await sendCommandToPane(paneId, spawnCommand, !insideTmux) 301: // Determine session/window names for output 302: const sessionName = insideTmux ? 'current' : SWARM_SESSION_NAME 303: const windowName = insideTmux ? 'current' : 'swarm-view' 304: setAppState(prev => ({ 305: ...prev, 306: teamContext: { 307: ...prev.teamContext, 308: teamName: teamName ?? prev.teamContext?.teamName ?? 'default', 309: teamFilePath: prev.teamContext?.teamFilePath ?? '', 310: leadAgentId: prev.teamContext?.leadAgentId ?? '', 311: teammates: { 312: ...(prev.teamContext?.teammates || {}), 313: [teammateId]: { 314: name: sanitizedName, 315: agentType: agent_type, 316: color: teammateColor, 317: tmuxSessionName: sessionName, 318: tmuxPaneId: paneId, 319: cwd: workingDir, 320: spawnedAt: Date.now(), 321: }, 322: }, 323: }, 324: })) 325: // Register background task so teammates appear in the tasks pill/dialog 326: registerOutOfProcessTeammateTask(setAppState, { 327: teammateId, 328: sanitizedName, 329: teamName, 330: teammateColor, 331: prompt, 332: plan_mode_required, 333: paneId, 334: insideTmux, 335: backendType: detectionResult.backend.type, 336: toolUseId: context.toolUseId, 337: }) 338: // Register agent in the team file 339: const teamFile = await readTeamFileAsync(teamName) 340: if (!teamFile) { 341: throw new Error( 342: `Team "${teamName}" does not exist. Call spawnTeam first to create the team.`, 343: ) 344: } 345: teamFile.members.push({ 346: agentId: teammateId, 347: name: sanitizedName, 348: agentType: agent_type, 349: model, 350: prompt, 351: color: teammateColor, 352: planModeRequired: plan_mode_required, 353: joinedAt: Date.now(), 354: tmuxPaneId: paneId, 355: cwd: workingDir, 356: subscriptions: [], 357: backendType: detectionResult.backend.type, 358: }) 359: await writeTeamFileAsync(teamName, teamFile) 360: // Send initial instructions to teammate via mailbox 361: // The teammate's inbox poller will pick this up and submit it as their first turn 362: await writeToMailbox( 363: sanitizedName, 364: { 365: from: TEAM_LEAD_NAME, 366: text: prompt, 367: timestamp: new Date().toISOString(), 368: }, 369: teamName, 370: ) 371: return { 372: data: { 373: teammate_id: teammateId, 374: agent_id: teammateId, 375: agent_type, 376: model, 377: name: sanitizedName, 378: color: teammateColor, 379: tmux_session_name: sessionName, 380: tmux_window_name: windowName, 381: tmux_pane_id: paneId, 382: team_name: teamName, 383: is_splitpane: true, 384: plan_mode_required, 385: }, 386: } 387: } 388: async function handleSpawnSeparateWindow( 389: input: SpawnInput, 390: context: ToolUseContext, 391: ): Promise<{ data: SpawnOutput }> { 392: const { setAppState, getAppState } = context 393: const { name, prompt, agent_type, cwd, plan_mode_required } = input 394: const model = resolveTeammateModel(input.model, getAppState().mainLoopModel) 395: if (!name || !prompt) { 396: throw new Error('name and prompt are required for spawn operation') 397: } 398: const appState = getAppState() 399: const teamName = input.team_name || appState.teamContext?.teamName 400: if (!teamName) { 401: throw new Error( 402: 'team_name is required for spawn operation. Either provide team_name in input or call spawnTeam first to establish team context.', 403: ) 404: } 405: const uniqueName = await generateUniqueTeammateName(name, teamName) 406: const sanitizedName = sanitizeAgentName(uniqueName) 407: const teammateId = formatAgentId(sanitizedName, teamName) 408: const windowName = `teammate-${sanitizeName(sanitizedName)}` 409: const workingDir = cwd || getCwd() 410: await ensureSession(SWARM_SESSION_NAME) 411: const teammateColor = assignTeammateColor(teammateId) 412: const createWindowResult = await execFileNoThrow(TMUX_COMMAND, [ 413: 'new-window', 414: '-t', 415: SWARM_SESSION_NAME, 416: '-n', 417: windowName, 418: '-P', 419: '-F', 420: '#{pane_id}', 421: ]) 422: if (createWindowResult.code !== 0) { 423: throw new Error( 424: `Failed to create tmux window: ${createWindowResult.stderr}`, 425: ) 426: } 427: const paneId = createWindowResult.stdout.trim() 428: const binaryPath = getTeammateCommand() 429: const teammateArgs = [ 430: `--agent-id ${quote([teammateId])}`, 431: `--agent-name ${quote([sanitizedName])}`, 432: `--team-name ${quote([teamName])}`, 433: `--agent-color ${quote([teammateColor])}`, 434: `--parent-session-id ${quote([getSessionId()])}`, 435: plan_mode_required ? '--plan-mode-required' : '', 436: agent_type ? `--agent-type ${quote([agent_type])}` : '', 437: ] 438: .filter(Boolean) 439: .join(' ') 440: // Build CLI flags to propagate to teammate 441: // Pass plan_mode_required to prevent inheriting bypass permissions 442: let inheritedFlags = buildInheritedCliFlags({ 443: planModeRequired: plan_mode_required, 444: permissionMode: appState.toolPermissionContext.mode, 445: }) 446: // If teammate has a custom model, add --model flag (or replace inherited one) 447: if (model) { 448: // Remove any inherited --model flag first 449: inheritedFlags = inheritedFlags 450: .split(' ') 451: .filter((flag, i, arr) => flag !== '--model' && arr[i - 1] !== '--model') 452: .join(' ') 453: inheritedFlags = inheritedFlags 454: ? `${inheritedFlags} --model ${quote([model])}` 455: : `--model ${quote([model])}` 456: } 457: const flagsStr = inheritedFlags ? ` ${inheritedFlags}` : '' 458: // Propagate env vars that teammates need but may not inherit from tmux split-window shells. 459: // Includes CLAUDECODE, CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS, and API provider vars. 460: const envStr = buildInheritedEnvVars() 461: const spawnCommand = `cd ${quote([workingDir])} && env ${envStr} ${quote([binaryPath])} ${teammateArgs}${flagsStr}` 462: // Send the command to the new window 463: const sendKeysResult = await execFileNoThrow(TMUX_COMMAND, [ 464: 'send-keys', 465: '-t', 466: `${SWARM_SESSION_NAME}:${windowName}`, 467: spawnCommand, 468: 'Enter', 469: ]) 470: if (sendKeysResult.code !== 0) { 471: throw new Error( 472: `Failed to send command to tmux window: ${sendKeysResult.stderr}`, 473: ) 474: } 475: setAppState(prev => ({ 476: ...prev, 477: teamContext: { 478: ...prev.teamContext, 479: teamName: teamName ?? prev.teamContext?.teamName ?? 'default', 480: teamFilePath: prev.teamContext?.teamFilePath ?? '', 481: leadAgentId: prev.teamContext?.leadAgentId ?? '', 482: teammates: { 483: ...(prev.teamContext?.teammates || {}), 484: [teammateId]: { 485: name: sanitizedName, 486: agentType: agent_type, 487: color: teammateColor, 488: tmuxSessionName: SWARM_SESSION_NAME, 489: tmuxPaneId: paneId, 490: cwd: workingDir, 491: spawnedAt: Date.now(), 492: }, 493: }, 494: }, 495: })) 496: // Register background task so tmux teammates appear in the tasks pill/dialog 497: // Separate window spawns are always outside tmux (external swarm session) 498: registerOutOfProcessTeammateTask(setAppState, { 499: teammateId, 500: sanitizedName, 501: teamName, 502: teammateColor, 503: prompt, 504: plan_mode_required, 505: paneId, 506: insideTmux: false, 507: backendType: 'tmux', 508: toolUseId: context.toolUseId, 509: }) 510: const teamFile = await readTeamFileAsync(teamName) 511: if (!teamFile) { 512: throw new Error( 513: `Team "${teamName}" does not exist. Call spawnTeam first to create the team.`, 514: ) 515: } 516: teamFile.members.push({ 517: agentId: teammateId, 518: name: sanitizedName, 519: agentType: agent_type, 520: model, 521: prompt, 522: color: teammateColor, 523: planModeRequired: plan_mode_required, 524: joinedAt: Date.now(), 525: tmuxPaneId: paneId, 526: cwd: workingDir, 527: subscriptions: [], 528: backendType: 'tmux', 529: }) 530: await writeTeamFileAsync(teamName, teamFile) 531: await writeToMailbox( 532: sanitizedName, 533: { 534: from: TEAM_LEAD_NAME, 535: text: prompt, 536: timestamp: new Date().toISOString(), 537: }, 538: teamName, 539: ) 540: return { 541: data: { 542: teammate_id: teammateId, 543: agent_id: teammateId, 544: agent_type, 545: model, 546: name: sanitizedName, 547: color: teammateColor, 548: tmux_session_name: SWARM_SESSION_NAME, 549: tmux_window_name: windowName, 550: tmux_pane_id: paneId, 551: team_name: teamName, 552: is_splitpane: false, 553: plan_mode_required, 554: }, 555: } 556: } 557: function registerOutOfProcessTeammateTask( 558: setAppState: (updater: (prev: AppState) => AppState) => void, 559: { 560: teammateId, 561: sanitizedName, 562: teamName, 563: teammateColor, 564: prompt, 565: plan_mode_required, 566: paneId, 567: insideTmux, 568: backendType, 569: toolUseId, 570: }: { 571: teammateId: string 572: sanitizedName: string 573: teamName: string 574: teammateColor: string 575: prompt: string 576: plan_mode_required?: boolean 577: paneId: string 578: insideTmux: boolean 579: backendType: BackendType 580: toolUseId?: string 581: }, 582: ): void { 583: const taskId = generateTaskId('in_process_teammate') 584: const description = `${sanitizedName}: ${prompt.substring(0, 50)}${prompt.length > 50 ? '...' : ''}` 585: const abortController = new AbortController() 586: const taskState: InProcessTeammateTaskState = { 587: ...createTaskStateBase( 588: taskId, 589: 'in_process_teammate', 590: description, 591: toolUseId, 592: ), 593: type: 'in_process_teammate', 594: status: 'running', 595: identity: { 596: agentId: teammateId, 597: agentName: sanitizedName, 598: teamName, 599: color: teammateColor, 600: planModeRequired: plan_mode_required ?? false, 601: parentSessionId: getSessionId(), 602: }, 603: prompt, 604: abortController, 605: awaitingPlanApproval: false, 606: permissionMode: plan_mode_required ? 'plan' : 'default', 607: isIdle: false, 608: shutdownRequested: false, 609: lastReportedToolCount: 0, 610: lastReportedTokenCount: 0, 611: pendingUserMessages: [], 612: } 613: registerTask(taskState, setAppState) 614: abortController.signal.addEventListener( 615: 'abort', 616: () => { 617: if (isPaneBackend(backendType)) { 618: void getBackendByType(backendType).killPane(paneId, !insideTmux) 619: } 620: }, 621: { once: true }, 622: ) 623: } 624: async function handleSpawnInProcess( 625: input: SpawnInput, 626: context: ToolUseContext, 627: ): Promise<{ data: SpawnOutput }> { 628: const { setAppState, getAppState } = context 629: const { name, prompt, agent_type, plan_mode_required } = input 630: const model = resolveTeammateModel(input.model, getAppState().mainLoopModel) 631: if (!name || !prompt) { 632: throw new Error('name and prompt are required for spawn operation') 633: } 634: const appState = getAppState() 635: const teamName = input.team_name || appState.teamContext?.teamName 636: if (!teamName) { 637: throw new Error( 638: 'team_name is required for spawn operation. Either provide team_name in input or call spawnTeam first to establish team context.', 639: ) 640: } 641: const uniqueName = await generateUniqueTeammateName(name, teamName) 642: const sanitizedName = sanitizeAgentName(uniqueName) 643: const teammateId = formatAgentId(sanitizedName, teamName) 644: const teammateColor = assignTeammateColor(teammateId) 645: let agentDefinition: CustomAgentDefinition | undefined 646: if (agent_type) { 647: const allAgents = context.options.agentDefinitions.activeAgents 648: const foundAgent = allAgents.find(a => a.agentType === agent_type) 649: if (foundAgent && isCustomAgent(foundAgent)) { 650: agentDefinition = foundAgent 651: } 652: logForDebugging( 653: `[handleSpawnInProcess] agent_type=${agent_type}, found=${!!agentDefinition}`, 654: ) 655: } 656: const config: InProcessSpawnConfig = { 657: name: sanitizedName, 658: teamName, 659: prompt, 660: color: teammateColor, 661: planModeRequired: plan_mode_required ?? false, 662: model, 663: } 664: const result = await spawnInProcessTeammate(config, context) 665: if (!result.success) { 666: throw new Error(result.error ?? 'Failed to spawn in-process teammate') 667: } 668: logForDebugging( 669: `[handleSpawnInProcess] spawn result: taskId=${result.taskId}, hasContext=${!!result.teammateContext}, hasAbort=${!!result.abortController}`, 670: ) 671: if (result.taskId && result.teammateContext && result.abortController) { 672: startInProcessTeammate({ 673: identity: { 674: agentId: teammateId, 675: agentName: sanitizedName, 676: teamName, 677: color: teammateColor, 678: planModeRequired: plan_mode_required ?? false, 679: parentSessionId: result.teammateContext.parentSessionId, 680: }, 681: taskId: result.taskId, 682: prompt, 683: description: input.description, 684: model, 685: agentDefinition, 686: teammateContext: result.teammateContext, 687: toolUseContext: { ...context, messages: [] }, 688: abortController: result.abortController, 689: invokingRequestId: input.invokingRequestId, 690: }) 691: logForDebugging( 692: `[handleSpawnInProcess] Started agent execution for ${teammateId}`, 693: ) 694: } 695: setAppState(prev => { 696: const needsLeaderSetup = !prev.teamContext?.leadAgentId 697: const leadAgentId = needsLeaderSetup 698: ? formatAgentId(TEAM_LEAD_NAME, teamName) 699: : prev.teamContext!.leadAgentId 700: const existingTeammates = prev.teamContext?.teammates || {} 701: const leadEntry = needsLeaderSetup 702: ? { 703: [leadAgentId]: { 704: name: TEAM_LEAD_NAME, 705: agentType: TEAM_LEAD_NAME, 706: color: assignTeammateColor(leadAgentId), 707: tmuxSessionName: 'in-process', 708: tmuxPaneId: 'leader', 709: cwd: getCwd(), 710: spawnedAt: Date.now(), 711: }, 712: } 713: : {} 714: return { 715: ...prev, 716: teamContext: { 717: ...prev.teamContext, 718: teamName: teamName ?? prev.teamContext?.teamName ?? 'default', 719: teamFilePath: prev.teamContext?.teamFilePath ?? '', 720: leadAgentId, 721: teammates: { 722: ...existingTeammates, 723: ...leadEntry, 724: [teammateId]: { 725: name: sanitizedName, 726: agentType: agent_type, 727: color: teammateColor, 728: tmuxSessionName: 'in-process', 729: tmuxPaneId: 'in-process', 730: cwd: getCwd(), 731: spawnedAt: Date.now(), 732: }, 733: }, 734: }, 735: } 736: }) 737: const teamFile = await readTeamFileAsync(teamName) 738: if (!teamFile) { 739: throw new Error( 740: `Team "${teamName}" does not exist. Call spawnTeam first to create the team.`, 741: ) 742: } 743: teamFile.members.push({ 744: agentId: teammateId, 745: name: sanitizedName, 746: agentType: agent_type, 747: model, 748: prompt, 749: color: teammateColor, 750: planModeRequired: plan_mode_required, 751: joinedAt: Date.now(), 752: tmuxPaneId: 'in-process', 753: cwd: getCwd(), 754: subscriptions: [], 755: backendType: 'in-process', 756: }) 757: await writeTeamFileAsync(teamName, teamFile) 758: return { 759: data: { 760: teammate_id: teammateId, 761: agent_id: teammateId, 762: agent_type, 763: model, 764: name: sanitizedName, 765: color: teammateColor, 766: tmux_session_name: 'in-process', 767: tmux_window_name: 'in-process', 768: tmux_pane_id: 'in-process', 769: team_name: teamName, 770: is_splitpane: false, 771: plan_mode_required, 772: }, 773: } 774: } 775: async function handleSpawn( 776: input: SpawnInput, 777: context: ToolUseContext, 778: ): Promise<{ data: SpawnOutput }> { 779: if (isInProcessEnabled()) { 780: return handleSpawnInProcess(input, context) 781: } 782: try { 783: await detectAndGetBackend() 784: } catch (error) { 785: if (getTeammateModeFromSnapshot() !== 'auto') { 786: throw error 787: } 788: logForDebugging( 789: `[handleSpawn] No pane backend available, falling back to in-process: ${errorMessage(error)}`, 790: ) 791: markInProcessFallback() 792: return handleSpawnInProcess(input, context) 793: } 794: const useSplitPane = input.use_splitpane !== false 795: if (useSplitPane) { 796: return handleSpawnSplitPane(input, context) 797: } 798: return handleSpawnSeparateWindow(input, context) 799: } 800: export async function spawnTeammate( 801: config: SpawnTeammateConfig, 802: context: ToolUseContext, 803: ): Promise<{ data: SpawnOutput }> { 804: return handleSpawn(config, context) 805: }

File: src/tools/SkillTool/constants.ts

typescript 1: export const SKILL_TOOL_NAME = 'Skill'

File: src/tools/SkillTool/prompt.ts

typescript 1: import { memoize } from 'lodash-es' 2: import type { Command } from 'src/commands.js' 3: import { 4: getCommandName, 5: getSkillToolCommands, 6: getSlashCommandToolSkills, 7: } from 'src/commands.js' 8: import { COMMAND_NAME_TAG } from '../../constants/xml.js' 9: import { stringWidth } from '../../ink/stringWidth.js' 10: import { 11: type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 12: logEvent, 13: } from '../../services/analytics/index.js' 14: import { count } from '../../utils/array.js' 15: import { logForDebugging } from '../../utils/debug.js' 16: import { toError } from '../../utils/errors.js' 17: import { truncate } from '../../utils/format.js' 18: import { logError } from '../../utils/log.js' 19: export const SKILL_BUDGET_CONTEXT_PERCENT = 0.01 20: export const CHARS_PER_TOKEN = 4 21: export const DEFAULT_CHAR_BUDGET = 8_000 22: export const MAX_LISTING_DESC_CHARS = 250 23: export function getCharBudget(contextWindowTokens?: number): number { 24: if (Number(process.env.SLASH_COMMAND_TOOL_CHAR_BUDGET)) { 25: return Number(process.env.SLASH_COMMAND_TOOL_CHAR_BUDGET) 26: } 27: if (contextWindowTokens) { 28: return Math.floor( 29: contextWindowTokens * CHARS_PER_TOKEN * SKILL_BUDGET_CONTEXT_PERCENT, 30: ) 31: } 32: return DEFAULT_CHAR_BUDGET 33: } 34: function getCommandDescription(cmd: Command): string { 35: const desc = cmd.whenToUse 36: ? `${cmd.description} - ${cmd.whenToUse}` 37: : cmd.description 38: return desc.length > MAX_LISTING_DESC_CHARS 39: ? desc.slice(0, MAX_LISTING_DESC_CHARS - 1) + '\u2026' 40: : desc 41: } 42: function formatCommandDescription(cmd: Command): string { 43: const displayName = getCommandName(cmd) 44: if ( 45: cmd.name !== displayName && 46: cmd.type === 'prompt' && 47: cmd.source === 'plugin' 48: ) { 49: logForDebugging( 50: `Skill prompt: showing "${cmd.name}" (userFacingName="${displayName}")`, 51: ) 52: } 53: return `- ${cmd.name}: ${getCommandDescription(cmd)}` 54: } 55: const MIN_DESC_LENGTH = 20 56: export function formatCommandsWithinBudget( 57: commands: Command[], 58: contextWindowTokens?: number, 59: ): string { 60: if (commands.length === 0) return '' 61: const budget = getCharBudget(contextWindowTokens) 62: // Try full descriptions first 63: const fullEntries = commands.map(cmd => ({ 64: cmd, 65: full: formatCommandDescription(cmd), 66: })) 67: // join('\n') produces N-1 newlines for N entries 68: const fullTotal = 69: fullEntries.reduce((sum, e) => sum + stringWidth(e.full), 0) + 70: (fullEntries.length - 1) 71: if (fullTotal <= budget) { 72: return fullEntries.map(e => e.full).join('\n') 73: } 74: const bundledIndices = new Set<number>() 75: const restCommands: Command[] = [] 76: for (let i = 0; i < commands.length; i++) { 77: const cmd = commands[i]! 78: if (cmd.type === 'prompt' && cmd.source === 'bundled') { 79: bundledIndices.add(i) 80: } else { 81: restCommands.push(cmd) 82: } 83: } 84: const bundledChars = fullEntries.reduce( 85: (sum, e, i) => 86: bundledIndices.has(i) ? sum + stringWidth(e.full) + 1 : sum, 87: 0, 88: ) 89: const remainingBudget = budget - bundledChars 90: if (restCommands.length === 0) { 91: return fullEntries.map(e => e.full).join('\n') 92: } 93: const restNameOverhead = 94: restCommands.reduce((sum, cmd) => sum + stringWidth(cmd.name) + 4, 0) + 95: (restCommands.length - 1) 96: const availableForDescs = remainingBudget - restNameOverhead 97: const maxDescLen = Math.floor(availableForDescs / restCommands.length) 98: if (maxDescLen < MIN_DESC_LENGTH) { 99: if (process.env.USER_TYPE === 'ant') { 100: logEvent('tengu_skill_descriptions_truncated', { 101: skill_count: commands.length, 102: budget, 103: full_total: fullTotal, 104: truncation_mode: 105: 'names_only' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 106: max_desc_length: maxDescLen, 107: bundled_count: bundledIndices.size, 108: bundled_chars: bundledChars, 109: }) 110: } 111: return commands 112: .map((cmd, i) => 113: bundledIndices.has(i) ? fullEntries[i]!.full : `- ${cmd.name}`, 114: ) 115: .join('\n') 116: } 117: const truncatedCount = count( 118: restCommands, 119: cmd => stringWidth(getCommandDescription(cmd)) > maxDescLen, 120: ) 121: if (process.env.USER_TYPE === 'ant') { 122: logEvent('tengu_skill_descriptions_truncated', { 123: skill_count: commands.length, 124: budget, 125: full_total: fullTotal, 126: truncation_mode: 127: 'description_trimmed' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 128: max_desc_length: maxDescLen, 129: truncated_count: truncatedCount, 130: bundled_count: bundledIndices.size, 131: bundled_chars: bundledChars, 132: }) 133: } 134: return commands 135: .map((cmd, i) => { 136: if (bundledIndices.has(i)) return fullEntries[i]!.full 137: const description = getCommandDescription(cmd) 138: return `- ${cmd.name}: ${truncate(description, maxDescLen)}` 139: }) 140: .join('\n') 141: } 142: export const getPrompt = memoize(async (_cwd: string): Promise<string> => { 143: return `Execute a skill within the main conversation 144: When users ask you to perform tasks, check if any of the available skills match. Skills provide specialized capabilities and domain knowledge. 145: When users reference a "slash command" or "/<something>" (e.g., "/commit", "/review-pr"), they are referring to a skill. Use this tool to invoke it. 146: How to invoke: 147: - Use this tool with the skill name and optional arguments 148: - Examples: 149: - \`skill: "pdf"\` - invoke the pdf skill 150: - \`skill: "commit", args: "-m 'Fix bug'"\` - invoke with arguments 151: - \`skill: "review-pr", args: "123"\` - invoke with arguments 152: - \`skill: "ms-office-suite:pdf"\` - invoke using fully qualified name 153: Important: 154: - Available skills are listed in system-reminder messages in the conversation 155: - When a skill matches the user's request, this is a BLOCKING REQUIREMENT: invoke the relevant Skill tool BEFORE generating any other response about the task 156: - NEVER mention a skill without actually calling this tool 157: - Do not invoke a skill that is already running 158: - Do not use this tool for built-in CLI commands (like /help, /clear, etc.) 159: - If you see a <${COMMAND_NAME_TAG}> tag in the current conversation turn, the skill has ALREADY been loaded - follow the instructions directly instead of calling this tool again 160: ` 161: }) 162: export async function getSkillToolInfo(cwd: string): Promise<{ 163: totalCommands: number 164: includedCommands: number 165: }> { 166: const agentCommands = await getSkillToolCommands(cwd) 167: return { 168: totalCommands: agentCommands.length, 169: includedCommands: agentCommands.length, 170: } 171: } 172: export function getLimitedSkillToolCommands(cwd: string): Promise<Command[]> { 173: return getSkillToolCommands(cwd) 174: } 175: export function clearPromptCache(): void { 176: getPrompt.cache?.clear?.() 177: } 178: export async function getSkillInfo(cwd: string): Promise<{ 179: totalSkills: number 180: includedSkills: number 181: }> { 182: try { 183: const skills = await getSlashCommandToolSkills(cwd) 184: return { 185: totalSkills: skills.length, 186: includedSkills: skills.length, 187: } 188: } catch (error) { 189: logError(toError(error)) 190: return { 191: totalSkills: 0, 192: includedSkills: 0, 193: } 194: } 195: }

File: src/tools/SkillTool/SkillTool.ts

typescript 1: import { feature } from 'bun:bundle' 2: import type { ToolResultBlockParam } from '@anthropic-ai/sdk/resources/index.mjs' 3: import uniqBy from 'lodash-es/uniqBy.js' 4: import { dirname } from 'path' 5: import { getProjectRoot } from 'src/bootstrap/state.js' 6: import { 7: builtInCommandNames, 8: findCommand, 9: getCommands, 10: type PromptCommand, 11: } from 'src/commands.js' 12: import type { 13: Tool, 14: ToolCallProgress, 15: ToolResult, 16: ToolUseContext, 17: ValidationResult, 18: } from 'src/Tool.js' 19: import { buildTool, type ToolDef } from 'src/Tool.js' 20: import type { Command } from 'src/types/command.js' 21: import type { 22: AssistantMessage, 23: AttachmentMessage, 24: Message, 25: SystemMessage, 26: UserMessage, 27: } from 'src/types/message.js' 28: import { logForDebugging } from 'src/utils/debug.js' 29: import type { PermissionDecision } from 'src/utils/permissions/PermissionResult.js' 30: import { getRuleByContentsForTool } from 'src/utils/permissions/permissions.js' 31: import { 32: isOfficialMarketplaceName, 33: parsePluginIdentifier, 34: } from 'src/utils/plugins/pluginIdentifier.js' 35: import { buildPluginCommandTelemetryFields } from 'src/utils/telemetry/pluginTelemetry.js' 36: import { z } from 'zod/v4' 37: import { 38: addInvokedSkill, 39: clearInvokedSkillsForAgent, 40: getSessionId, 41: } from '../../bootstrap/state.js' 42: import { COMMAND_MESSAGE_TAG } from '../../constants/xml.js' 43: import type { CanUseToolFn } from '../../hooks/useCanUseTool.js' 44: import { 45: type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 46: type AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, 47: logEvent, 48: } from '../../services/analytics/index.js' 49: import { getAgentContext } from '../../utils/agentContext.js' 50: import { errorMessage } from '../../utils/errors.js' 51: import { 52: extractResultText, 53: prepareForkedCommandContext, 54: } from '../../utils/forkedAgent.js' 55: import { parseFrontmatter } from '../../utils/frontmatterParser.js' 56: import { lazySchema } from '../../utils/lazySchema.js' 57: import { createUserMessage, normalizeMessages } from '../../utils/messages.js' 58: import type { ModelAlias } from '../../utils/model/aliases.js' 59: import { resolveSkillModelOverride } from '../../utils/model/model.js' 60: import { recordSkillUsage } from '../../utils/suggestions/skillUsageTracking.js' 61: import { createAgentId } from '../../utils/uuid.js' 62: import { runAgent } from '../AgentTool/runAgent.js' 63: import { 64: getToolUseIDFromParentMessage, 65: tagMessagesWithToolUseID, 66: } from '../utils.js' 67: import { SKILL_TOOL_NAME } from './constants.js' 68: import { getPrompt } from './prompt.js' 69: import { 70: renderToolResultMessage, 71: renderToolUseErrorMessage, 72: renderToolUseMessage, 73: renderToolUseProgressMessage, 74: renderToolUseRejectedMessage, 75: } from './UI.js' 76: async function getAllCommands(context: ToolUseContext): Promise<Command[]> { 77: const mcpSkills = context 78: .getAppState() 79: .mcp.commands.filter( 80: cmd => cmd.type === 'prompt' && cmd.loadedFrom === 'mcp', 81: ) 82: if (mcpSkills.length === 0) return getCommands(getProjectRoot()) 83: const localCommands = await getCommands(getProjectRoot()) 84: return uniqBy([...localCommands, ...mcpSkills], 'name') 85: } 86: export type { SkillToolProgress as Progress } from '../../types/tools.js' 87: import type { SkillToolProgress as Progress } from '../../types/tools.js' 88: const remoteSkillModules = feature('EXPERIMENTAL_SKILL_SEARCH') 89: ? { 90: ...(require('../../services/skillSearch/remoteSkillState.js') as typeof import('../../services/skillSearch/remoteSkillState.js')), 91: ...(require('../../services/skillSearch/remoteSkillLoader.js') as typeof import('../../services/skillSearch/remoteSkillLoader.js')), 92: ...(require('../../services/skillSearch/telemetry.js') as typeof import('../../services/skillSearch/telemetry.js')), 93: ...(require('../../services/skillSearch/featureCheck.js') as typeof import('../../services/skillSearch/featureCheck.js')), 94: } 95: : null 96: async function executeForkedSkill( 97: command: Command & { type: 'prompt' }, 98: commandName: string, 99: args: string | undefined, 100: context: ToolUseContext, 101: canUseTool: CanUseToolFn, 102: parentMessage: AssistantMessage, 103: onProgress?: ToolCallProgress<Progress>, 104: ): Promise<ToolResult<Output>> { 105: const startTime = Date.now() 106: const agentId = createAgentId() 107: const isBuiltIn = builtInCommandNames().has(commandName) 108: const isOfficialSkill = isOfficialMarketplaceSkill(command) 109: const isBundled = command.source === 'bundled' 110: const forkedSanitizedName = 111: isBuiltIn || isBundled || isOfficialSkill ? commandName : 'custom' 112: const wasDiscoveredField = 113: feature('EXPERIMENTAL_SKILL_SEARCH') && 114: remoteSkillModules!.isSkillSearchEnabled() 115: ? { 116: was_discovered: 117: context.discoveredSkillNames?.has(commandName) ?? false, 118: } 119: : {} 120: const pluginMarketplace = command.pluginInfo 121: ? parsePluginIdentifier(command.pluginInfo.repository).marketplace 122: : undefined 123: const queryDepth = context.queryTracking?.depth ?? 0 124: const parentAgentId = getAgentContext()?.agentId 125: logEvent('tengu_skill_tool_invocation', { 126: command_name: 127: forkedSanitizedName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 128: _PROTO_skill_name: 129: commandName as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, 130: execution_context: 131: 'fork' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 132: invocation_trigger: (queryDepth > 0 133: ? 'nested-skill' 134: : 'claude-proactive') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 135: query_depth: queryDepth, 136: ...(parentAgentId && { 137: parent_agent_id: 138: parentAgentId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 139: }), 140: ...wasDiscoveredField, 141: ...(process.env.USER_TYPE === 'ant' && { 142: skill_name: 143: commandName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 144: skill_source: 145: command.source as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 146: ...(command.loadedFrom && { 147: skill_loaded_from: 148: command.loadedFrom as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 149: }), 150: ...(command.kind && { 151: skill_kind: 152: command.kind as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 153: }), 154: }), 155: ...(command.pluginInfo && { 156: _PROTO_plugin_name: command.pluginInfo.pluginManifest 157: .name as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, 158: ...(pluginMarketplace && { 159: _PROTO_marketplace_name: 160: pluginMarketplace as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, 161: }), 162: plugin_name: (isOfficialSkill 163: ? command.pluginInfo.pluginManifest.name 164: : 'third-party') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 165: plugin_repository: (isOfficialSkill 166: ? command.pluginInfo.repository 167: : 'third-party') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 168: ...buildPluginCommandTelemetryFields(command.pluginInfo), 169: }), 170: }) 171: const { modifiedGetAppState, baseAgent, promptMessages, skillContent } = 172: await prepareForkedCommandContext(command, args || '', context) 173: // Merge skill's effort into the agent definition so runAgent applies it 174: const agentDefinition = 175: command.effort !== undefined 176: ? { ...baseAgent, effort: command.effort } 177: : baseAgent 178: const agentMessages: Message[] = [] 179: logForDebugging( 180: `SkillTool executing forked skill ${commandName} with agent ${agentDefinition.agentType}`, 181: ) 182: try { 183: for await (const message of runAgent({ 184: agentDefinition, 185: promptMessages, 186: toolUseContext: { 187: ...context, 188: getAppState: modifiedGetAppState, 189: }, 190: canUseTool, 191: isAsync: false, 192: querySource: 'agent:custom', 193: model: command.model as ModelAlias | undefined, 194: availableTools: context.options.tools, 195: override: { agentId }, 196: })) { 197: agentMessages.push(message) 198: if ( 199: (message.type === 'assistant' || message.type === 'user') && 200: onProgress 201: ) { 202: const normalizedNew = normalizeMessages([message]) 203: for (const m of normalizedNew) { 204: const hasToolContent = m.message.content.some( 205: c => c.type === 'tool_use' || c.type === 'tool_result', 206: ) 207: if (hasToolContent) { 208: onProgress({ 209: toolUseID: `skill_${parentMessage.message.id}`, 210: data: { 211: message: m, 212: type: 'skill_progress', 213: prompt: skillContent, 214: agentId, 215: }, 216: }) 217: } 218: } 219: } 220: } 221: const resultText = extractResultText( 222: agentMessages, 223: 'Skill execution completed', 224: ) 225: agentMessages.length = 0 226: const durationMs = Date.now() - startTime 227: logForDebugging( 228: `SkillTool forked skill ${commandName} completed in ${durationMs}ms`, 229: ) 230: return { 231: data: { 232: success: true, 233: commandName, 234: status: 'forked', 235: agentId, 236: result: resultText, 237: }, 238: } 239: } finally { 240: clearInvokedSkillsForAgent(agentId) 241: } 242: } 243: export const inputSchema = lazySchema(() => 244: z.object({ 245: skill: z 246: .string() 247: .describe('The skill name. E.g., "commit", "review-pr", or "pdf"'), 248: args: z.string().optional().describe('Optional arguments for the skill'), 249: }), 250: ) 251: type InputSchema = ReturnType<typeof inputSchema> 252: export const outputSchema = lazySchema(() => { 253: const inlineOutputSchema = z.object({ 254: success: z.boolean().describe('Whether the skill is valid'), 255: commandName: z.string().describe('The name of the skill'), 256: allowedTools: z 257: .array(z.string()) 258: .optional() 259: .describe('Tools allowed by this skill'), 260: model: z.string().optional().describe('Model override if specified'), 261: status: z.literal('inline').optional().describe('Execution status'), 262: }) 263: const forkedOutputSchema = z.object({ 264: success: z.boolean().describe('Whether the skill completed successfully'), 265: commandName: z.string().describe('The name of the skill'), 266: status: z.literal('forked').describe('Execution status'), 267: agentId: z 268: .string() 269: .describe('The ID of the sub-agent that executed the skill'), 270: result: z.string().describe('The result from the forked skill execution'), 271: }) 272: return z.union([inlineOutputSchema, forkedOutputSchema]) 273: }) 274: type OutputSchema = ReturnType<typeof outputSchema> 275: export type Output = z.input<OutputSchema> 276: export const SkillTool: Tool<InputSchema, Output, Progress> = buildTool({ 277: name: SKILL_TOOL_NAME, 278: searchHint: 'invoke a slash-command skill', 279: maxResultSizeChars: 100_000, 280: get inputSchema(): InputSchema { 281: return inputSchema() 282: }, 283: get outputSchema(): OutputSchema { 284: return outputSchema() 285: }, 286: description: async ({ skill }) => `Execute skill: ${skill}`, 287: prompt: async () => getPrompt(getProjectRoot()), 288: toAutoClassifierInput: ({ skill }) => skill ?? '', 289: async validateInput({ skill }, context): Promise<ValidationResult> { 290: // Skills are just skill names, no arguments 291: const trimmed = skill.trim() 292: if (!trimmed) { 293: return { 294: result: false, 295: message: `Invalid skill format: ${skill}`, 296: errorCode: 1, 297: } 298: } 299: // Remove leading slash if present (for compatibility) 300: const hasLeadingSlash = trimmed.startsWith('/') 301: if (hasLeadingSlash) { 302: logEvent('tengu_skill_tool_slash_prefix', {}) 303: } 304: const normalizedCommandName = hasLeadingSlash 305: ? trimmed.substring(1) 306: : trimmed 307: if ( 308: feature('EXPERIMENTAL_SKILL_SEARCH') && 309: process.env.USER_TYPE === 'ant' 310: ) { 311: const slug = remoteSkillModules!.stripCanonicalPrefix( 312: normalizedCommandName, 313: ) 314: if (slug !== null) { 315: const meta = remoteSkillModules!.getDiscoveredRemoteSkill(slug) 316: if (!meta) { 317: return { 318: result: false, 319: message: `Remote skill ${slug} was not discovered in this session. Use DiscoverSkills to find remote skills first.`, 320: errorCode: 6, 321: } 322: } 323: return { result: true } 324: } 325: } 326: const commands = await getAllCommands(context) 327: const foundCommand = findCommand(normalizedCommandName, commands) 328: if (!foundCommand) { 329: return { 330: result: false, 331: message: `Unknown skill: ${normalizedCommandName}`, 332: errorCode: 2, 333: } 334: } 335: if (foundCommand.disableModelInvocation) { 336: return { 337: result: false, 338: message: `Skill ${normalizedCommandName} cannot be used with ${SKILL_TOOL_NAME} tool due to disable-model-invocation`, 339: errorCode: 4, 340: } 341: } 342: if (foundCommand.type !== 'prompt') { 343: return { 344: result: false, 345: message: `Skill ${normalizedCommandName} is not a prompt-based skill`, 346: errorCode: 5, 347: } 348: } 349: return { result: true } 350: }, 351: async checkPermissions( 352: { skill, args }, 353: context, 354: ): Promise<PermissionDecision> { 355: const trimmed = skill.trim() 356: const commandName = trimmed.startsWith('/') ? trimmed.substring(1) : trimmed 357: const appState = context.getAppState() 358: const permissionContext = appState.toolPermissionContext 359: const commands = await getAllCommands(context) 360: const commandObj = findCommand(commandName, commands) 361: const ruleMatches = (ruleContent: string): boolean => { 362: const normalizedRule = ruleContent.startsWith('/') 363: ? ruleContent.substring(1) 364: : ruleContent 365: if (normalizedRule === commandName) { 366: return true 367: } 368: if (normalizedRule.endsWith(':*')) { 369: const prefix = normalizedRule.slice(0, -2) 370: return commandName.startsWith(prefix) 371: } 372: return false 373: } 374: const denyRules = getRuleByContentsForTool( 375: permissionContext, 376: SkillTool as Tool, 377: 'deny', 378: ) 379: for (const [ruleContent, rule] of denyRules.entries()) { 380: if (ruleMatches(ruleContent)) { 381: return { 382: behavior: 'deny', 383: message: `Skill execution blocked by permission rules`, 384: decisionReason: { 385: type: 'rule', 386: rule, 387: }, 388: } 389: } 390: } 391: if ( 392: feature('EXPERIMENTAL_SKILL_SEARCH') && 393: process.env.USER_TYPE === 'ant' 394: ) { 395: const slug = remoteSkillModules!.stripCanonicalPrefix(commandName) 396: if (slug !== null) { 397: return { 398: behavior: 'allow', 399: updatedInput: { skill, args }, 400: decisionReason: undefined, 401: } 402: } 403: } 404: const allowRules = getRuleByContentsForTool( 405: permissionContext, 406: SkillTool as Tool, 407: 'allow', 408: ) 409: for (const [ruleContent, rule] of allowRules.entries()) { 410: if (ruleMatches(ruleContent)) { 411: return { 412: behavior: 'allow', 413: updatedInput: { skill, args }, 414: decisionReason: { 415: type: 'rule', 416: rule, 417: }, 418: } 419: } 420: } 421: if ( 422: commandObj?.type === 'prompt' && 423: skillHasOnlySafeProperties(commandObj) 424: ) { 425: return { 426: behavior: 'allow', 427: updatedInput: { skill, args }, 428: decisionReason: undefined, 429: } 430: } 431: const suggestions = [ 432: { 433: type: 'addRules' as const, 434: rules: [ 435: { 436: toolName: SKILL_TOOL_NAME, 437: ruleContent: commandName, 438: }, 439: ], 440: behavior: 'allow' as const, 441: destination: 'localSettings' as const, 442: }, 443: { 444: type: 'addRules' as const, 445: rules: [ 446: { 447: toolName: SKILL_TOOL_NAME, 448: ruleContent: `${commandName}:*`, 449: }, 450: ], 451: behavior: 'allow' as const, 452: destination: 'localSettings' as const, 453: }, 454: ] 455: return { 456: behavior: 'ask', 457: message: `Execute skill: ${commandName}`, 458: decisionReason: undefined, 459: suggestions, 460: updatedInput: { skill, args }, 461: metadata: commandObj ? { command: commandObj } : undefined, 462: } 463: }, 464: async call( 465: { skill, args }, 466: context, 467: canUseTool, 468: parentMessage, 469: onProgress?, 470: ): Promise<ToolResult<Output>> { 471: const trimmed = skill.trim() 472: const commandName = trimmed.startsWith('/') ? trimmed.substring(1) : trimmed 473: if ( 474: feature('EXPERIMENTAL_SKILL_SEARCH') && 475: process.env.USER_TYPE === 'ant' 476: ) { 477: const slug = remoteSkillModules!.stripCanonicalPrefix(commandName) 478: if (slug !== null) { 479: return executeRemoteSkill(slug, commandName, parentMessage, context) 480: } 481: } 482: const commands = await getAllCommands(context) 483: const command = findCommand(commandName, commands) 484: recordSkillUsage(commandName) 485: if (command?.type === 'prompt' && command.context === 'fork') { 486: return executeForkedSkill( 487: command, 488: commandName, 489: args, 490: context, 491: canUseTool, 492: parentMessage, 493: onProgress, 494: ) 495: } 496: const { processPromptSlashCommand } = await import( 497: 'src/utils/processUserInput/processSlashCommand.js' 498: ) 499: const processedCommand = await processPromptSlashCommand( 500: commandName, 501: args || '', // Pass args if provided 502: commands, 503: context, 504: ) 505: if (!processedCommand.shouldQuery) { 506: throw new Error('Command processing failed') 507: } 508: const allowedTools = processedCommand.allowedTools || [] 509: const model = processedCommand.model 510: const effort = command?.type === 'prompt' ? command.effort : undefined 511: const isBuiltIn = builtInCommandNames().has(commandName) 512: const isBundled = command?.type === 'prompt' && command.source === 'bundled' 513: const isOfficialSkill = 514: command?.type === 'prompt' && isOfficialMarketplaceSkill(command) 515: const sanitizedCommandName = 516: isBuiltIn || isBundled || isOfficialSkill ? commandName : 'custom' 517: const wasDiscoveredField = 518: feature('EXPERIMENTAL_SKILL_SEARCH') && 519: remoteSkillModules!.isSkillSearchEnabled() 520: ? { 521: was_discovered: 522: context.discoveredSkillNames?.has(commandName) ?? false, 523: } 524: : {} 525: const pluginMarketplace = 526: command?.type === 'prompt' && command.pluginInfo 527: ? parsePluginIdentifier(command.pluginInfo.repository).marketplace 528: : undefined 529: const queryDepth = context.queryTracking?.depth ?? 0 530: const parentAgentId = getAgentContext()?.agentId 531: logEvent('tengu_skill_tool_invocation', { 532: command_name: 533: sanitizedCommandName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 534: _PROTO_skill_name: 535: commandName as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, 536: execution_context: 537: 'inline' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 538: invocation_trigger: (queryDepth > 0 539: ? 'nested-skill' 540: : 'claude-proactive') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 541: query_depth: queryDepth, 542: ...(parentAgentId && { 543: parent_agent_id: 544: parentAgentId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 545: }), 546: ...wasDiscoveredField, 547: ...(process.env.USER_TYPE === 'ant' && { 548: skill_name: 549: commandName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 550: ...(command?.type === 'prompt' && { 551: skill_source: 552: command.source as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 553: }), 554: ...(command?.loadedFrom && { 555: skill_loaded_from: 556: command.loadedFrom as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 557: }), 558: ...(command?.kind && { 559: skill_kind: 560: command.kind as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 561: }), 562: }), 563: ...(command?.type === 'prompt' && 564: command.pluginInfo && { 565: _PROTO_plugin_name: command.pluginInfo.pluginManifest 566: .name as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, 567: ...(pluginMarketplace && { 568: _PROTO_marketplace_name: 569: pluginMarketplace as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, 570: }), 571: plugin_name: (isOfficialSkill 572: ? command.pluginInfo.pluginManifest.name 573: : 'third-party') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 574: plugin_repository: (isOfficialSkill 575: ? command.pluginInfo.repository 576: : 'third-party') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 577: ...buildPluginCommandTelemetryFields(command.pluginInfo), 578: }), 579: }) 580: const toolUseID = getToolUseIDFromParentMessage( 581: parentMessage, 582: SKILL_TOOL_NAME, 583: ) 584: const newMessages = tagMessagesWithToolUseID( 585: processedCommand.messages.filter( 586: (m): m is UserMessage | AttachmentMessage | SystemMessage => { 587: if (m.type === 'progress') { 588: return false 589: } 590: if (m.type === 'user' && 'message' in m) { 591: const content = m.message.content 592: if ( 593: typeof content === 'string' && 594: content.includes(`<${COMMAND_MESSAGE_TAG}>`) 595: ) { 596: return false 597: } 598: } 599: return true 600: }, 601: ), 602: toolUseID, 603: ) 604: logForDebugging( 605: `SkillTool returning ${newMessages.length} newMessages for skill ${commandName}`, 606: ) 607: return { 608: data: { 609: success: true, 610: commandName, 611: allowedTools: allowedTools.length > 0 ? allowedTools : undefined, 612: model, 613: }, 614: newMessages, 615: contextModifier(ctx) { 616: let modifiedContext = ctx 617: if (allowedTools.length > 0) { 618: const previousGetAppState = modifiedContext.getAppState 619: modifiedContext = { 620: ...modifiedContext, 621: getAppState() { 622: const appState = previousGetAppState() 623: return { 624: ...appState, 625: toolPermissionContext: { 626: ...appState.toolPermissionContext, 627: alwaysAllowRules: { 628: ...appState.toolPermissionContext.alwaysAllowRules, 629: command: [ 630: ...new Set([ 631: ...(appState.toolPermissionContext.alwaysAllowRules 632: .command || []), 633: ...allowedTools, 634: ]), 635: ], 636: }, 637: }, 638: } 639: }, 640: } 641: } 642: if (model) { 643: modifiedContext = { 644: ...modifiedContext, 645: options: { 646: ...modifiedContext.options, 647: mainLoopModel: resolveSkillModelOverride( 648: model, 649: ctx.options.mainLoopModel, 650: ), 651: }, 652: } 653: } 654: if (effort !== undefined) { 655: const previousGetAppState = modifiedContext.getAppState 656: modifiedContext = { 657: ...modifiedContext, 658: getAppState() { 659: const appState = previousGetAppState() 660: return { 661: ...appState, 662: effortValue: effort, 663: } 664: }, 665: } 666: } 667: return modifiedContext 668: }, 669: } 670: }, 671: mapToolResultToToolResultBlockParam( 672: result: Output, 673: toolUseID: string, 674: ): ToolResultBlockParam { 675: if ('status' in result && result.status === 'forked') { 676: return { 677: type: 'tool_result' as const, 678: tool_use_id: toolUseID, 679: content: `Skill "${result.commandName}" completed (forked execution).\n\nResult:\n${result.result}`, 680: } 681: } 682: return { 683: type: 'tool_result' as const, 684: tool_use_id: toolUseID, 685: content: `Launching skill: ${result.commandName}`, 686: } 687: }, 688: renderToolResultMessage, 689: renderToolUseMessage, 690: renderToolUseProgressMessage, 691: renderToolUseRejectedMessage, 692: renderToolUseErrorMessage, 693: } satisfies ToolDef<InputSchema, Output, Progress>) 694: const SAFE_SKILL_PROPERTIES = new Set([ 695: 'type', 696: 'progressMessage', 697: 'contentLength', 698: 'argNames', 699: 'model', 700: 'effort', 701: 'source', 702: 'pluginInfo', 703: 'disableNonInteractive', 704: 'skillRoot', 705: 'context', 706: 'agent', 707: 'getPromptForCommand', 708: 'frontmatterKeys', 709: 'name', 710: 'description', 711: 'hasUserSpecifiedDescription', 712: 'isEnabled', 713: 'isHidden', 714: 'aliases', 715: 'isMcp', 716: 'argumentHint', 717: 'whenToUse', 718: 'paths', 719: 'version', 720: 'disableModelInvocation', 721: 'userInvocable', 722: 'loadedFrom', 723: 'immediate', 724: 'userFacingName', 725: ]) 726: function skillHasOnlySafeProperties(command: Command): boolean { 727: for (const key of Object.keys(command)) { 728: if (SAFE_SKILL_PROPERTIES.has(key)) { 729: continue 730: } 731: const value = (command as Record<string, unknown>)[key] 732: if (value === undefined || value === null) { 733: continue 734: } 735: if (Array.isArray(value) && value.length === 0) { 736: continue 737: } 738: if ( 739: typeof value === 'object' && 740: !Array.isArray(value) && 741: Object.keys(value).length === 0 742: ) { 743: continue 744: } 745: return false 746: } 747: return true 748: } 749: function isOfficialMarketplaceSkill(command: PromptCommand): boolean { 750: if (command.source !== 'plugin' || !command.pluginInfo?.repository) { 751: return false 752: } 753: return isOfficialMarketplaceName( 754: parsePluginIdentifier(command.pluginInfo.repository).marketplace, 755: ) 756: } 757: function extractUrlScheme(url: string): 'gs' | 'http' | 'https' | 's3' { 758: if (url.startsWith('gs://')) return 'gs' 759: if (url.startsWith('https://')) return 'https' 760: if (url.startsWith('http://')) return 'http' 761: if (url.startsWith('s3://')) return 's3' 762: return 'gs' 763: } 764: async function executeRemoteSkill( 765: slug: string, 766: commandName: string, 767: parentMessage: AssistantMessage, 768: context: ToolUseContext, 769: ): Promise<ToolResult<Output>> { 770: const { getDiscoveredRemoteSkill, loadRemoteSkill, logRemoteSkillLoaded } = 771: remoteSkillModules! 772: const meta = getDiscoveredRemoteSkill(slug) 773: if (!meta) { 774: throw new Error( 775: `Remote skill ${slug} was not discovered in this session. Use DiscoverSkills to find remote skills first.`, 776: ) 777: } 778: const urlScheme = extractUrlScheme(meta.url) 779: let loadResult 780: try { 781: loadResult = await loadRemoteSkill(slug, meta.url) 782: } catch (e) { 783: const msg = errorMessage(e) 784: logRemoteSkillLoaded({ 785: slug, 786: cacheHit: false, 787: latencyMs: 0, 788: urlScheme, 789: error: msg, 790: }) 791: throw new Error(`Failed to load remote skill ${slug}: ${msg}`) 792: } 793: const { 794: cacheHit, 795: latencyMs, 796: skillPath, 797: content, 798: fileCount, 799: totalBytes, 800: fetchMethod, 801: } = loadResult 802: logRemoteSkillLoaded({ 803: slug, 804: cacheHit, 805: latencyMs, 806: urlScheme, 807: fileCount, 808: totalBytes, 809: fetchMethod, 810: }) 811: const queryDepth = context.queryTracking?.depth ?? 0 812: const parentAgentId = getAgentContext()?.agentId 813: logEvent('tengu_skill_tool_invocation', { 814: command_name: 815: 'remote_skill' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 816: _PROTO_skill_name: 817: commandName as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, 818: execution_context: 819: 'remote' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 820: invocation_trigger: (queryDepth > 0 821: ? 'nested-skill' 822: : 'claude-proactive') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 823: query_depth: queryDepth, 824: ...(parentAgentId && { 825: parent_agent_id: 826: parentAgentId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 827: }), 828: was_discovered: true, 829: is_remote: true, 830: remote_cache_hit: cacheHit, 831: remote_load_latency_ms: latencyMs, 832: ...(process.env.USER_TYPE === 'ant' && { 833: skill_name: 834: commandName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 835: remote_slug: 836: slug as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 837: }), 838: }) 839: recordSkillUsage(commandName) 840: logForDebugging( 841: `SkillTool loaded remote skill ${slug} (cacheHit=${cacheHit}, ${latencyMs}ms, ${content.length} chars)`, 842: ) 843: const { content: bodyContent } = parseFrontmatter(content, skillPath) 844: const skillDir = dirname(skillPath) 845: const normalizedDir = 846: process.platform === 'win32' ? skillDir.replace(/\\/g, '/') : skillDir 847: let finalContent = `Base directory for this skill: ${normalizedDir}\n\n${bodyContent}` 848: finalContent = finalContent.replace(/\$\{CLAUDE_SKILL_DIR\}/g, normalizedDir) 849: finalContent = finalContent.replace( 850: /\$\{CLAUDE_SESSION_ID\}/g, 851: getSessionId(), 852: ) 853: addInvokedSkill( 854: commandName, 855: skillPath, 856: finalContent, 857: getAgentContext()?.agentId ?? null, 858: ) 859: const toolUseID = getToolUseIDFromParentMessage( 860: parentMessage, 861: SKILL_TOOL_NAME, 862: ) 863: return { 864: data: { success: true, commandName, status: 'inline' }, 865: newMessages: tagMessagesWithToolUseID( 866: [createUserMessage({ content: finalContent, isMeta: true })], 867: toolUseID, 868: ), 869: } 870: }

File: src/tools/SkillTool/UI.tsx

typescript 1: import type { ToolResultBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'; 2: import * as React from 'react'; 3: import { SubAgentProvider } from 'src/components/CtrlOToExpand.js'; 4: import { FallbackToolUseErrorMessage } from 'src/components/FallbackToolUseErrorMessage.js'; 5: import { FallbackToolUseRejectedMessage } from 'src/components/FallbackToolUseRejectedMessage.js'; 6: import type { z } from 'zod/v4'; 7: import type { Command } from '../../commands.js'; 8: import { Byline } from '../../components/design-system/Byline.js'; 9: import { Message as MessageComponent } from '../../components/Message.js'; 10: import { MessageResponse } from '../../components/MessageResponse.js'; 11: import { Box, Text } from '../../ink.js'; 12: import type { Tools } from '../../Tool.js'; 13: import type { ProgressMessage } from '../../types/message.js'; 14: import { buildSubagentLookups, EMPTY_LOOKUPS } from '../../utils/messages.js'; 15: import { plural } from '../../utils/stringUtils.js'; 16: import type { inputSchema, Output, Progress } from './SkillTool.js'; 17: type Input = z.infer<ReturnType<typeof inputSchema>>; 18: const MAX_PROGRESS_MESSAGES_TO_SHOW = 3; 19: const INITIALIZING_TEXT = 'Initializing…'; 20: export function renderToolResultMessage(output: Output): React.ReactNode { 21: if ('status' in output && output.status === 'forked') { 22: return <MessageResponse height={1}> 23: <Text> 24: <Byline>{['Done']}</Byline> 25: </Text> 26: </MessageResponse>; 27: } 28: const parts: string[] = ['Successfully loaded skill']; 29: if ('allowedTools' in output && output.allowedTools && output.allowedTools.length > 0) { 30: const count = output.allowedTools.length; 31: parts.push(`${count} ${plural(count, 'tool')} allowed`); 32: } 33: if ('model' in output && output.model) { 34: parts.push(output.model); 35: } 36: return <MessageResponse height={1}> 37: <Text> 38: <Byline>{parts}</Byline> 39: </Text> 40: </MessageResponse>; 41: } 42: export function renderToolUseMessage({ 43: skill 44: }: Partial<Input>, { 45: commands 46: }: { 47: commands?: Command[]; 48: }): React.ReactNode { 49: if (!skill) { 50: return null; 51: } 52: const command = commands?.find(c => c.name === skill); 53: const displayName = command?.loadedFrom === 'commands_DEPRECATED' ? `/${skill}` : skill; 54: return displayName; 55: } 56: export function renderToolUseProgressMessage(progressMessages: ProgressMessage<Progress>[], { 57: tools, 58: verbose 59: }: { 60: tools: Tools; 61: verbose: boolean; 62: }): React.ReactNode { 63: if (!progressMessages.length) { 64: return <MessageResponse height={1}> 65: <Text dimColor>{INITIALIZING_TEXT}</Text> 66: </MessageResponse>; 67: } 68: const displayedMessages = verbose ? progressMessages : progressMessages.slice(-MAX_PROGRESS_MESSAGES_TO_SHOW); 69: const hiddenCount = progressMessages.length - displayedMessages.length; 70: const { 71: inProgressToolUseIDs 72: } = buildSubagentLookups(progressMessages.map(pm => pm.data)); 73: return <MessageResponse> 74: <Box flexDirection="column"> 75: <SubAgentProvider> 76: {displayedMessages.map(progressMessage => <Box key={progressMessage.uuid} height={1} overflow="hidden"> 77: <MessageComponent message={progressMessage.data.message} lookups={EMPTY_LOOKUPS} addMargin={false} tools={tools} commands={[]} verbose={verbose} inProgressToolUseIDs={inProgressToolUseIDs} progressMessagesForMessage={[]} shouldAnimate={false} shouldShowDot={false} style="condensed" isTranscriptMode={false} isStatic={true} /> 78: </Box>)} 79: </SubAgentProvider> 80: {hiddenCount > 0 && <Text dimColor> 81: +{hiddenCount} more tool {plural(hiddenCount, 'use')} 82: </Text>} 83: </Box> 84: </MessageResponse>; 85: } 86: export function renderToolUseRejectedMessage(_input: Input, { 87: progressMessagesForMessage, 88: tools, 89: verbose 90: }: { 91: progressMessagesForMessage: ProgressMessage<Progress>[]; 92: tools: Tools; 93: verbose: boolean; 94: }): React.ReactNode { 95: return <> 96: {renderToolUseProgressMessage(progressMessagesForMessage, { 97: tools, 98: verbose 99: })} 100: <FallbackToolUseRejectedMessage /> 101: </>; 102: } 103: export function renderToolUseErrorMessage(result: ToolResultBlockParam['content'], { 104: progressMessagesForMessage, 105: tools, 106: verbose 107: }: { 108: progressMessagesForMessage: ProgressMessage<Progress>[]; 109: tools: Tools; 110: verbose: boolean; 111: }): React.ReactNode { 112: return <> 113: {renderToolUseProgressMessage(progressMessagesForMessage, { 114: tools, 115: verbose 116: })} 117: <FallbackToolUseErrorMessage result={result} verbose={verbose} /> 118: </>; 119: }

File: src/tools/SleepTool/prompt.ts

typescript 1: import { TICK_TAG } from '../../constants/xml.js' 2: export const SLEEP_TOOL_NAME = 'Sleep' 3: export const DESCRIPTION = 'Wait for a specified duration' 4: export const SLEEP_TOOL_PROMPT = `Wait for a specified duration. The user can interrupt the sleep at any time. 5: Use this when the user tells you to sleep or rest, when you have nothing to do, or when you're waiting for something. 6: You may receive <${TICK_TAG}> prompts — these are periodic check-ins. Look for useful work to do before sleeping. 7: You can call this concurrently with other tools — it won't interfere with them. 8: Prefer this over \`Bash(sleep ...)\` — it doesn't hold a shell process. 9: Each wake-up costs an API call, but the prompt cache expires after 5 minutes of inactivity — balance accordingly.`

File: src/tools/SyntheticOutputTool/SyntheticOutputTool.ts

typescript 1: import { Ajv } from 'ajv' 2: import { z } from 'zod/v4' 3: import type { Tool, ToolInputJSONSchema } from '../../Tool.js' 4: import { buildTool, type ToolDef } from '../../Tool.js' 5: import { TelemetrySafeError_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS } from '../../utils/errors.js' 6: import { lazySchema } from '../../utils/lazySchema.js' 7: import type { PermissionResult } from '../../utils/permissions/PermissionResult.js' 8: import { jsonStringify } from '../../utils/slowOperations.js' 9: const inputSchema = lazySchema(() => z.object({}).passthrough()) 10: type InputSchema = ReturnType<typeof inputSchema> 11: const outputSchema = lazySchema(() => 12: z.string().describe('Structured output tool result'), 13: ) 14: type OutputSchema = ReturnType<typeof outputSchema> 15: export type Output = z.infer<OutputSchema> 16: export const SYNTHETIC_OUTPUT_TOOL_NAME = 'StructuredOutput' 17: export function isSyntheticOutputToolEnabled(opts: { 18: isNonInteractiveSession: boolean 19: }): boolean { 20: return opts.isNonInteractiveSession 21: } 22: export const SyntheticOutputTool = buildTool({ 23: isMcp: false, 24: isEnabled() { 25: return true 26: }, 27: isConcurrencySafe() { 28: return true 29: }, 30: isReadOnly() { 31: return true 32: }, 33: isOpenWorld() { 34: return false 35: }, 36: name: SYNTHETIC_OUTPUT_TOOL_NAME, 37: searchHint: 'return the final response as structured JSON', 38: maxResultSizeChars: 100_000, 39: async description(): Promise<string> { 40: return 'Return structured output in the requested format' 41: }, 42: async prompt(): Promise<string> { 43: return `Use this tool to return your final response in the requested structured format. You MUST call this tool exactly once at the end of your response to provide the structured output.` 44: }, 45: get inputSchema(): InputSchema { 46: return inputSchema() 47: }, 48: get outputSchema(): OutputSchema { 49: return outputSchema() 50: }, 51: async call(input) { 52: return { 53: data: 'Structured output provided successfully', 54: structured_output: input, 55: } 56: }, 57: async checkPermissions(input): Promise<PermissionResult> { 58: return { 59: behavior: 'allow', 60: updatedInput: input, 61: } 62: }, 63: renderToolUseMessage(input: Record<string, unknown>) { 64: const keys = Object.keys(input) 65: if (keys.length === 0) return null 66: if (keys.length <= 3) { 67: return keys.map(k => `${k}: ${jsonStringify(input[k])}`).join(', ') 68: } 69: return `${keys.length} fields: ${keys.slice(0, 3).join(', ')}…` 70: }, 71: renderToolUseRejectedMessage() { 72: return 'Structured output rejected' 73: }, 74: renderToolUseErrorMessage() { 75: return 'Structured output error' 76: }, 77: renderToolUseProgressMessage() { 78: return null 79: }, 80: renderToolResultMessage(output: string) { 81: return output 82: }, 83: mapToolResultToToolResultBlockParam(content: string, toolUseID: string) { 84: return { 85: tool_use_id: toolUseID, 86: type: 'tool_result' as const, 87: content, 88: } 89: }, 90: } satisfies ToolDef<InputSchema, Output>) 91: type CreateResult = { tool: Tool<InputSchema> } | { error: string } 92: const toolCache = new WeakMap<object, CreateResult>() 93: export function createSyntheticOutputTool( 94: jsonSchema: Record<string, unknown>, 95: ): CreateResult { 96: const cached = toolCache.get(jsonSchema) 97: if (cached) return cached 98: const result = buildSyntheticOutputTool(jsonSchema) 99: toolCache.set(jsonSchema, result) 100: return result 101: } 102: function buildSyntheticOutputTool( 103: jsonSchema: Record<string, unknown>, 104: ): CreateResult { 105: try { 106: const ajv = new Ajv({ allErrors: true }) 107: const isValidSchema = ajv.validateSchema(jsonSchema) 108: if (!isValidSchema) { 109: return { error: ajv.errorsText(ajv.errors) } 110: } 111: const validateSchema = ajv.compile(jsonSchema) 112: return { 113: tool: { 114: ...SyntheticOutputTool, 115: inputJSONSchema: jsonSchema as ToolInputJSONSchema, 116: async call(input) { 117: const isValid = validateSchema(input) 118: if (!isValid) { 119: const errors = validateSchema.errors 120: ?.map(e => `${e.instancePath || 'root'}: ${e.message}`) 121: .join(', ') 122: throw new TelemetrySafeError_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS( 123: `Output does not match required schema: ${errors}`, 124: `StructuredOutput schema mismatch: ${(errors ?? '').slice(0, 150)}`, 125: ) 126: } 127: return { 128: data: 'Structured output provided successfully', 129: structured_output: input, 130: } 131: }, 132: }, 133: } 134: } catch (e) { 135: return { error: e instanceof Error ? e.message : String(e) } 136: } 137: }

File: src/tools/TaskCreateTool/constants.ts

typescript 1: export const TASK_CREATE_TOOL_NAME = 'TaskCreate'

File: src/tools/TaskCreateTool/prompt.ts

typescript 1: import { isAgentSwarmsEnabled } from '../../utils/agentSwarmsEnabled.js' 2: export const DESCRIPTION = 'Create a new task in the task list' 3: export function getPrompt(): string { 4: const teammateContext = isAgentSwarmsEnabled() 5: ? ' and potentially assigned to teammates' 6: : '' 7: const teammateTips = isAgentSwarmsEnabled() 8: ? `- Include enough detail in the description for another agent to understand and complete the task 9: - New tasks are created with status 'pending' and no owner - use TaskUpdate with the \`owner\` parameter to assign them 10: ` 11: : '' 12: return `Use this tool to create a structured task list for your current coding session. This helps you track progress, organize complex tasks, and demonstrate thoroughness to the user. 13: It also helps the user understand the progress of the task and overall progress of their requests. 14: ## When to Use This Tool 15: Use this tool proactively in these scenarios: 16: - Complex multi-step tasks - When a task requires 3 or more distinct steps or actions 17: - Non-trivial and complex tasks - Tasks that require careful planning or multiple operations${teammateContext} 18: - Plan mode - When using plan mode, create a task list to track the work 19: - User explicitly requests todo list - When the user directly asks you to use the todo list 20: - User provides multiple tasks - When users provide a list of things to be done (numbered or comma-separated) 21: - After receiving new instructions - Immediately capture user requirements as tasks 22: - When you start working on a task - Mark it as in_progress BEFORE beginning work 23: - After completing a task - Mark it as completed and add any new follow-up tasks discovered during implementation 24: ## When NOT to Use This Tool 25: Skip using this tool when: 26: - There is only a single, straightforward task 27: - The task is trivial and tracking it provides no organizational benefit 28: - The task can be completed in less than 3 trivial steps 29: - The task is purely conversational or informational 30: NOTE that you should not use this tool if there is only one trivial task to do. In this case you are better off just doing the task directly. 31: ## Task Fields 32: - **subject**: A brief, actionable title in imperative form (e.g., "Fix authentication bug in login flow") 33: - **description**: What needs to be done 34: - **activeForm** (optional): Present continuous form shown in the spinner when the task is in_progress (e.g., "Fixing authentication bug"). If omitted, the spinner shows the subject instead. 35: All tasks are created with status \`pending\`. 36: ## Tips 37: - Create tasks with clear, specific subjects that describe the outcome 38: - After creating tasks, use TaskUpdate to set up dependencies (blocks/blockedBy) if needed 39: ${teammateTips}- Check TaskList first to avoid creating duplicate tasks 40: ` 41: }

File: src/tools/TaskCreateTool/TaskCreateTool.ts

typescript 1: import { z } from 'zod/v4' 2: import { buildTool, type ToolDef } from '../../Tool.js' 3: import { 4: executeTaskCreatedHooks, 5: getTaskCreatedHookMessage, 6: } from '../../utils/hooks.js' 7: import { lazySchema } from '../../utils/lazySchema.js' 8: import { 9: createTask, 10: deleteTask, 11: getTaskListId, 12: isTodoV2Enabled, 13: } from '../../utils/tasks.js' 14: import { getAgentName, getTeamName } from '../../utils/teammate.js' 15: import { TASK_CREATE_TOOL_NAME } from './constants.js' 16: import { DESCRIPTION, getPrompt } from './prompt.js' 17: const inputSchema = lazySchema(() => 18: z.strictObject({ 19: subject: z.string().describe('A brief title for the task'), 20: description: z.string().describe('What needs to be done'), 21: activeForm: z 22: .string() 23: .optional() 24: .describe( 25: 'Present continuous form shown in spinner when in_progress (e.g., "Running tests")', 26: ), 27: metadata: z 28: .record(z.string(), z.unknown()) 29: .optional() 30: .describe('Arbitrary metadata to attach to the task'), 31: }), 32: ) 33: type InputSchema = ReturnType<typeof inputSchema> 34: const outputSchema = lazySchema(() => 35: z.object({ 36: task: z.object({ 37: id: z.string(), 38: subject: z.string(), 39: }), 40: }), 41: ) 42: type OutputSchema = ReturnType<typeof outputSchema> 43: export type Output = z.infer<OutputSchema> 44: export const TaskCreateTool = buildTool({ 45: name: TASK_CREATE_TOOL_NAME, 46: searchHint: 'create a task in the task list', 47: maxResultSizeChars: 100_000, 48: async description() { 49: return DESCRIPTION 50: }, 51: async prompt() { 52: return getPrompt() 53: }, 54: get inputSchema(): InputSchema { 55: return inputSchema() 56: }, 57: get outputSchema(): OutputSchema { 58: return outputSchema() 59: }, 60: userFacingName() { 61: return 'TaskCreate' 62: }, 63: shouldDefer: true, 64: isEnabled() { 65: return isTodoV2Enabled() 66: }, 67: isConcurrencySafe() { 68: return true 69: }, 70: toAutoClassifierInput(input) { 71: return input.subject 72: }, 73: renderToolUseMessage() { 74: return null 75: }, 76: async call({ subject, description, activeForm, metadata }, context) { 77: const taskId = await createTask(getTaskListId(), { 78: subject, 79: description, 80: activeForm, 81: status: 'pending', 82: owner: undefined, 83: blocks: [], 84: blockedBy: [], 85: metadata, 86: }) 87: const blockingErrors: string[] = [] 88: const generator = executeTaskCreatedHooks( 89: taskId, 90: subject, 91: description, 92: getAgentName(), 93: getTeamName(), 94: undefined, 95: context?.abortController?.signal, 96: undefined, 97: context, 98: ) 99: for await (const result of generator) { 100: if (result.blockingError) { 101: blockingErrors.push(getTaskCreatedHookMessage(result.blockingError)) 102: } 103: } 104: if (blockingErrors.length > 0) { 105: await deleteTask(getTaskListId(), taskId) 106: throw new Error(blockingErrors.join('\n')) 107: } 108: context.setAppState(prev => { 109: if (prev.expandedView === 'tasks') return prev 110: return { ...prev, expandedView: 'tasks' as const } 111: }) 112: return { 113: data: { 114: task: { 115: id: taskId, 116: subject, 117: }, 118: }, 119: } 120: }, 121: mapToolResultToToolResultBlockParam(content, toolUseID) { 122: const { task } = content as Output 123: return { 124: tool_use_id: toolUseID, 125: type: 'tool_result', 126: content: `Task #${task.id} created successfully: ${task.subject}`, 127: } 128: }, 129: } satisfies ToolDef<InputSchema, Output>)

File: src/tools/TaskGetTool/constants.ts

typescript 1: export const TASK_GET_TOOL_NAME = 'TaskGet'

File: src/tools/TaskGetTool/prompt.ts

typescript 1: export const DESCRIPTION = 'Get a task by ID from the task list' 2: export const PROMPT = `Use this tool to retrieve a task by its ID from the task list. 3: ## When to Use This Tool 4: - When you need the full description and context before starting work on a task 5: - To understand task dependencies (what it blocks, what blocks it) 6: - After being assigned a task, to get complete requirements 7: ## Output 8: Returns full task details: 9: - **subject**: Task title 10: - **description**: Detailed requirements and context 11: - **status**: 'pending', 'in_progress', or 'completed' 12: - **blocks**: Tasks waiting on this one to complete 13: - **blockedBy**: Tasks that must complete before this one can start 14: ## Tips 15: - After fetching a task, verify its blockedBy list is empty before beginning work. 16: - Use TaskList to see all tasks in summary form. 17: `

File: src/tools/TaskGetTool/TaskGetTool.ts

typescript 1: import { z } from 'zod/v4' 2: import { buildTool, type ToolDef } from '../../Tool.js' 3: import { lazySchema } from '../../utils/lazySchema.js' 4: import { 5: getTask, 6: getTaskListId, 7: isTodoV2Enabled, 8: TaskStatusSchema, 9: } from '../../utils/tasks.js' 10: import { TASK_GET_TOOL_NAME } from './constants.js' 11: import { DESCRIPTION, PROMPT } from './prompt.js' 12: const inputSchema = lazySchema(() => 13: z.strictObject({ 14: taskId: z.string().describe('The ID of the task to retrieve'), 15: }), 16: ) 17: type InputSchema = ReturnType<typeof inputSchema> 18: const outputSchema = lazySchema(() => 19: z.object({ 20: task: z 21: .object({ 22: id: z.string(), 23: subject: z.string(), 24: description: z.string(), 25: status: TaskStatusSchema(), 26: blocks: z.array(z.string()), 27: blockedBy: z.array(z.string()), 28: }) 29: .nullable(), 30: }), 31: ) 32: type OutputSchema = ReturnType<typeof outputSchema> 33: export type Output = z.infer<OutputSchema> 34: export const TaskGetTool = buildTool({ 35: name: TASK_GET_TOOL_NAME, 36: searchHint: 'retrieve a task by ID', 37: maxResultSizeChars: 100_000, 38: async description() { 39: return DESCRIPTION 40: }, 41: async prompt() { 42: return PROMPT 43: }, 44: get inputSchema(): InputSchema { 45: return inputSchema() 46: }, 47: get outputSchema(): OutputSchema { 48: return outputSchema() 49: }, 50: userFacingName() { 51: return 'TaskGet' 52: }, 53: shouldDefer: true, 54: isEnabled() { 55: return isTodoV2Enabled() 56: }, 57: isConcurrencySafe() { 58: return true 59: }, 60: isReadOnly() { 61: return true 62: }, 63: toAutoClassifierInput(input) { 64: return input.taskId 65: }, 66: renderToolUseMessage() { 67: return null 68: }, 69: async call({ taskId }) { 70: const taskListId = getTaskListId() 71: const task = await getTask(taskListId, taskId) 72: if (!task) { 73: return { 74: data: { 75: task: null, 76: }, 77: } 78: } 79: return { 80: data: { 81: task: { 82: id: task.id, 83: subject: task.subject, 84: description: task.description, 85: status: task.status, 86: blocks: task.blocks, 87: blockedBy: task.blockedBy, 88: }, 89: }, 90: } 91: }, 92: mapToolResultToToolResultBlockParam(content, toolUseID) { 93: const { task } = content as Output 94: if (!task) { 95: return { 96: tool_use_id: toolUseID, 97: type: 'tool_result', 98: content: 'Task not found', 99: } 100: } 101: const lines = [ 102: `Task #${task.id}: ${task.subject}`, 103: `Status: ${task.status}`, 104: `Description: ${task.description}`, 105: ] 106: if (task.blockedBy.length > 0) { 107: lines.push(`Blocked by: ${task.blockedBy.map(id => `#${id}`).join(', ')}`) 108: } 109: if (task.blocks.length > 0) { 110: lines.push(`Blocks: ${task.blocks.map(id => `#${id}`).join(', ')}`) 111: } 112: return { 113: tool_use_id: toolUseID, 114: type: 'tool_result', 115: content: lines.join('\n'), 116: } 117: }, 118: } satisfies ToolDef<InputSchema, Output>)

File: src/tools/TaskListTool/constants.ts

typescript 1: export const TASK_LIST_TOOL_NAME = 'TaskList'

File: src/tools/TaskListTool/prompt.ts

typescript 1: import { isAgentSwarmsEnabled } from '../../utils/agentSwarmsEnabled.js' 2: export const DESCRIPTION = 'List all tasks in the task list' 3: export function getPrompt(): string { 4: const teammateUseCase = isAgentSwarmsEnabled() 5: ? `- Before assigning tasks to teammates, to see what's available 6: ` 7: : '' 8: const idDescription = isAgentSwarmsEnabled() 9: ? '- **id**: Task identifier (use with TaskGet, TaskUpdate)' 10: : '- **id**: Task identifier (use with TaskGet, TaskUpdate)' 11: const teammateWorkflow = isAgentSwarmsEnabled() 12: ? ` 13: ## Teammate Workflow 14: When working as a teammate: 15: 1. After completing your current task, call TaskList to find available work 16: 2. Look for tasks with status 'pending', no owner, and empty blockedBy 17: 3. **Prefer tasks in ID order** (lowest ID first) when multiple tasks are available, as earlier tasks often set up context for later ones 18: 4. Claim an available task using TaskUpdate (set \`owner\` to your name), or wait for leader assignment 19: 5. If blocked, focus on unblocking tasks or notify the team lead 20: ` 21: : '' 22: return `Use this tool to list all tasks in the task list. 23: ## When to Use This Tool 24: - To see what tasks are available to work on (status: 'pending', no owner, not blocked) 25: - To check overall progress on the project 26: - To find tasks that are blocked and need dependencies resolved 27: ${teammateUseCase}- After completing a task, to check for newly unblocked work or claim the next available task 28: - **Prefer working on tasks in ID order** (lowest ID first) when multiple tasks are available, as earlier tasks often set up context for later ones 29: ## Output 30: Returns a summary of each task: 31: ${idDescription} 32: - **subject**: Brief description of the task 33: - **status**: 'pending', 'in_progress', or 'completed' 34: - **owner**: Agent ID if assigned, empty if available 35: - **blockedBy**: List of open task IDs that must be resolved first (tasks with blockedBy cannot be claimed until dependencies resolve) 36: Use TaskGet with a specific task ID to view full details including description and comments. 37: ${teammateWorkflow}` 38: }

File: src/tools/TaskListTool/TaskListTool.ts

typescript 1: import { z } from 'zod/v4' 2: import { buildTool, type ToolDef } from '../../Tool.js' 3: import { lazySchema } from '../../utils/lazySchema.js' 4: import { 5: getTaskListId, 6: isTodoV2Enabled, 7: listTasks, 8: TaskStatusSchema, 9: } from '../../utils/tasks.js' 10: import { TASK_LIST_TOOL_NAME } from './constants.js' 11: import { DESCRIPTION, getPrompt } from './prompt.js' 12: const inputSchema = lazySchema(() => z.strictObject({})) 13: type InputSchema = ReturnType<typeof inputSchema> 14: const outputSchema = lazySchema(() => 15: z.object({ 16: tasks: z.array( 17: z.object({ 18: id: z.string(), 19: subject: z.string(), 20: status: TaskStatusSchema(), 21: owner: z.string().optional(), 22: blockedBy: z.array(z.string()), 23: }), 24: ), 25: }), 26: ) 27: type OutputSchema = ReturnType<typeof outputSchema> 28: export type Output = z.infer<OutputSchema> 29: export const TaskListTool = buildTool({ 30: name: TASK_LIST_TOOL_NAME, 31: searchHint: 'list all tasks', 32: maxResultSizeChars: 100_000, 33: async description() { 34: return DESCRIPTION 35: }, 36: async prompt() { 37: return getPrompt() 38: }, 39: get inputSchema(): InputSchema { 40: return inputSchema() 41: }, 42: get outputSchema(): OutputSchema { 43: return outputSchema() 44: }, 45: userFacingName() { 46: return 'TaskList' 47: }, 48: shouldDefer: true, 49: isEnabled() { 50: return isTodoV2Enabled() 51: }, 52: isConcurrencySafe() { 53: return true 54: }, 55: isReadOnly() { 56: return true 57: }, 58: renderToolUseMessage() { 59: return null 60: }, 61: async call() { 62: const taskListId = getTaskListId() 63: const allTasks = (await listTasks(taskListId)).filter( 64: t => !t.metadata?._internal, 65: ) 66: const resolvedTaskIds = new Set( 67: allTasks.filter(t => t.status === 'completed').map(t => t.id), 68: ) 69: const tasks = allTasks.map(task => ({ 70: id: task.id, 71: subject: task.subject, 72: status: task.status, 73: owner: task.owner, 74: blockedBy: task.blockedBy.filter(id => !resolvedTaskIds.has(id)), 75: })) 76: return { 77: data: { 78: tasks, 79: }, 80: } 81: }, 82: mapToolResultToToolResultBlockParam(content, toolUseID) { 83: const { tasks } = content as Output 84: if (tasks.length === 0) { 85: return { 86: tool_use_id: toolUseID, 87: type: 'tool_result', 88: content: 'No tasks found', 89: } 90: } 91: const lines = tasks.map(task => { 92: const owner = task.owner ? ` (${task.owner})` : '' 93: const blocked = 94: task.blockedBy.length > 0 95: ? ` [blocked by ${task.blockedBy.map(id => `#${id}`).join(', ')}]` 96: : '' 97: return `#${task.id} [${task.status}] ${task.subject}${owner}${blocked}` 98: }) 99: return { 100: tool_use_id: toolUseID, 101: type: 'tool_result', 102: content: lines.join('\n'), 103: } 104: }, 105: } satisfies ToolDef<InputSchema, Output>)

File: src/tools/TaskOutputTool/constants.ts

typescript 1: export const TASK_OUTPUT_TOOL_NAME = 'TaskOutput'

File: src/tools/TaskOutputTool/TaskOutputTool.tsx

typescript 1: import { c as _c } from "react/compiler-runtime"; 2: import React from 'react'; 3: import { z } from 'zod/v4'; 4: import { FallbackToolUseErrorMessage } from '../../components/FallbackToolUseErrorMessage.js'; 5: import { FallbackToolUseRejectedMessage } from '../../components/FallbackToolUseRejectedMessage.js'; 6: import { MessageResponse } from '../../components/MessageResponse.js'; 7: import { Box, Text } from '../../ink.js'; 8: import { useShortcutDisplay } from '../../keybindings/useShortcutDisplay.js'; 9: import type { TaskType } from '../../Task.js'; 10: import type { Tool } from '../../Tool.js'; 11: import { buildTool, type ToolDef } from '../../Tool.js'; 12: import type { LocalAgentTaskState } from '../../tasks/LocalAgentTask/LocalAgentTask.js'; 13: import type { LocalShellTaskState } from '../../tasks/LocalShellTask/guards.js'; 14: import type { RemoteAgentTaskState } from '../../tasks/RemoteAgentTask/RemoteAgentTask.js'; 15: import type { TaskState } from '../../tasks/types.js'; 16: import { AbortError } from '../../utils/errors.js'; 17: import { lazySchema } from '../../utils/lazySchema.js'; 18: import { extractTextContent } from '../../utils/messages.js'; 19: import { semanticBoolean } from '../../utils/semanticBoolean.js'; 20: import { sleep } from '../../utils/sleep.js'; 21: import { jsonParse } from '../../utils/slowOperations.js'; 22: import { countCharInString } from '../../utils/stringUtils.js'; 23: import { getTaskOutput } from '../../utils/task/diskOutput.js'; 24: import { updateTaskState } from '../../utils/task/framework.js'; 25: import { formatTaskOutput } from '../../utils/task/outputFormatting.js'; 26: import type { ThemeName } from '../../utils/theme.js'; 27: import { AgentPromptDisplay, AgentResponseDisplay } from '../AgentTool/UI.js'; 28: import BashToolResultMessage from '../BashTool/BashToolResultMessage.js'; 29: import { TASK_OUTPUT_TOOL_NAME } from './constants.js'; 30: const inputSchema = lazySchema(() => z.strictObject({ 31: task_id: z.string().describe('The task ID to get output from'), 32: block: semanticBoolean(z.boolean().default(true)).describe('Whether to wait for completion'), 33: timeout: z.number().min(0).max(600000).default(30000).describe('Max wait time in ms') 34: })); 35: type InputSchema = ReturnType<typeof inputSchema>; 36: type TaskOutputToolInput = z.infer<InputSchema>; 37: type TaskOutput = { 38: task_id: string; 39: task_type: TaskType; 40: status: string; 41: description: string; 42: output: string; 43: exitCode?: number | null; 44: error?: string; 45: prompt?: string; 46: result?: string; 47: }; 48: type TaskOutputToolOutput = { 49: retrieval_status: 'success' | 'timeout' | 'not_ready'; 50: task: TaskOutput | null; 51: }; 52: export type { TaskOutputProgress as Progress } from '../../types/tools.js'; 53: async function getTaskOutputData(task: TaskState): Promise<TaskOutput> { 54: let output: string; 55: if (task.type === 'local_bash') { 56: const bashTask = task as LocalShellTaskState; 57: const taskOutputObj = bashTask.shellCommand?.taskOutput; 58: if (taskOutputObj) { 59: const stdout = await taskOutputObj.getStdout(); 60: const stderr = taskOutputObj.getStderr(); 61: output = [stdout, stderr].filter(Boolean).join('\n'); 62: } else { 63: output = await getTaskOutput(task.id); 64: } 65: } else { 66: output = await getTaskOutput(task.id); 67: } 68: const baseOutput: TaskOutput = { 69: task_id: task.id, 70: task_type: task.type, 71: status: task.status, 72: description: task.description, 73: output 74: }; 75: if (task.type === 'local_bash') { 76: const bashTask = task as LocalShellTaskState; 77: return { 78: ...baseOutput, 79: exitCode: bashTask.result?.code ?? null 80: }; 81: } 82: if (task.type === 'local_agent') { 83: const agentTask = task as LocalAgentTaskState; 84: const cleanResult = agentTask.result ? extractTextContent(agentTask.result.content, '\n') : undefined; 85: return { 86: ...baseOutput, 87: prompt: agentTask.prompt, 88: result: cleanResult || output, 89: output: cleanResult || output, 90: error: agentTask.error 91: }; 92: } 93: if (task.type === 'remote_agent') { 94: const remoteTask = task as RemoteAgentTaskState; 95: return { 96: ...baseOutput, 97: prompt: remoteTask.command 98: }; 99: } 100: return baseOutput; 101: } 102: async function waitForTaskCompletion(taskId: string, getAppState: () => { 103: tasks?: Record<string, TaskState>; 104: }, timeoutMs: number, abortController?: AbortController): Promise<TaskState | null> { 105: const startTime = Date.now(); 106: while (Date.now() - startTime < timeoutMs) { 107: if (abortController?.signal.aborted) { 108: throw new AbortError(); 109: } 110: const state = getAppState(); 111: const task = state.tasks?.[taskId] as TaskState | undefined; 112: if (!task) { 113: return null; 114: } 115: if (task.status !== 'running' && task.status !== 'pending') { 116: return task; 117: } 118: await sleep(100); 119: } 120: const finalState = getAppState(); 121: return finalState.tasks?.[taskId] as TaskState ?? null; 122: } 123: export const TaskOutputTool: Tool<InputSchema, TaskOutputToolOutput> = buildTool({ 124: name: TASK_OUTPUT_TOOL_NAME, 125: searchHint: 'read output/logs from a background task', 126: maxResultSizeChars: 100_000, 127: shouldDefer: true, 128: aliases: ['AgentOutputTool', 'BashOutputTool'], 129: userFacingName() { 130: return 'Task Output'; 131: }, 132: get inputSchema(): InputSchema { 133: return inputSchema(); 134: }, 135: async description() { 136: return '[Deprecated] — prefer Read on the task output file path'; 137: }, 138: isConcurrencySafe(_input) { 139: return this.isReadOnly?.(_input) ?? false; 140: }, 141: isEnabled() { 142: return "external" !== 'ant'; 143: }, 144: isReadOnly(_input) { 145: return true; 146: }, 147: toAutoClassifierInput(input) { 148: return input.task_id; 149: }, 150: async prompt() { 151: return `DEPRECATED: Prefer using the Read tool on the task's output file path instead. Background tasks return their output file path in the tool result, and you receive a <task-notification> with the same path when the task completes — Read that file directly. 152: - Retrieves output from a running or completed task (background shell, agent, or remote session) 153: - Takes a task_id parameter identifying the task 154: - Returns the task output along with status information 155: - Use block=true (default) to wait for task completion 156: - Use block=false for non-blocking check of current status 157: - Task IDs can be found using the /tasks command 158: - Works with all task types: background shells, async agents, and remote sessions`; 159: }, 160: async validateInput({ 161: task_id 162: }, { 163: getAppState 164: }) { 165: if (!task_id) { 166: return { 167: result: false, 168: message: 'Task ID is required', 169: errorCode: 1 170: }; 171: } 172: const appState = getAppState(); 173: const task = appState.tasks?.[task_id] as TaskState | undefined; 174: if (!task) { 175: return { 176: result: false, 177: message: `No task found with ID: ${task_id}`, 178: errorCode: 2 179: }; 180: } 181: return { 182: result: true 183: }; 184: }, 185: async call(input: TaskOutputToolInput, toolUseContext, _canUseTool, _parentMessage, onProgress) { 186: const { 187: task_id, 188: block, 189: timeout 190: } = input; 191: const appState = toolUseContext.getAppState(); 192: const task = appState.tasks?.[task_id] as TaskState | undefined; 193: if (!task) { 194: throw new Error(`No task found with ID: ${task_id}`); 195: } 196: if (!block) { 197: if (task.status !== 'running' && task.status !== 'pending') { 198: updateTaskState(task_id, toolUseContext.setAppState, t => ({ 199: ...t, 200: notified: true 201: })); 202: return { 203: data: { 204: retrieval_status: 'success' as const, 205: task: await getTaskOutputData(task) 206: } 207: }; 208: } 209: return { 210: data: { 211: retrieval_status: 'not_ready' as const, 212: task: await getTaskOutputData(task) 213: } 214: }; 215: } 216: if (onProgress) { 217: onProgress({ 218: toolUseID: `task-output-waiting-${Date.now()}`, 219: data: { 220: type: 'waiting_for_task', 221: taskDescription: task.description, 222: taskType: task.type 223: } 224: }); 225: } 226: const completedTask = await waitForTaskCompletion(task_id, toolUseContext.getAppState, timeout, toolUseContext.abortController); 227: if (!completedTask) { 228: return { 229: data: { 230: retrieval_status: 'timeout' as const, 231: task: null 232: } 233: }; 234: } 235: if (completedTask.status === 'running' || completedTask.status === 'pending') { 236: return { 237: data: { 238: retrieval_status: 'timeout' as const, 239: task: await getTaskOutputData(completedTask) 240: } 241: }; 242: } 243: updateTaskState(task_id, toolUseContext.setAppState, t => ({ 244: ...t, 245: notified: true 246: })); 247: return { 248: data: { 249: retrieval_status: 'success' as const, 250: task: await getTaskOutputData(completedTask) 251: } 252: }; 253: }, 254: mapToolResultToToolResultBlockParam(data, toolUseID) { 255: const parts: string[] = []; 256: parts.push(`<retrieval_status>${data.retrieval_status}</retrieval_status>`); 257: if (data.task) { 258: parts.push(`<task_id>${data.task.task_id}</task_id>`); 259: parts.push(`<task_type>${data.task.task_type}</task_type>`); 260: parts.push(`<status>${data.task.status}</status>`); 261: if (data.task.exitCode !== undefined && data.task.exitCode !== null) { 262: parts.push(`<exit_code>${data.task.exitCode}</exit_code>`); 263: } 264: if (data.task.output?.trim()) { 265: const { 266: content 267: } = formatTaskOutput(data.task.output, data.task.task_id); 268: parts.push(`<output>\n${content.trimEnd()}\n</output>`); 269: } 270: if (data.task.error) { 271: parts.push(`<error>${data.task.error}</error>`); 272: } 273: } 274: return { 275: tool_use_id: toolUseID, 276: type: 'tool_result' as const, 277: content: parts.join('\n\n') 278: }; 279: }, 280: renderToolUseMessage(input) { 281: const { 282: block = true 283: } = input; 284: if (!block) { 285: return 'non-blocking'; 286: } 287: return ''; 288: }, 289: renderToolUseTag(input) { 290: if (!input.task_id) { 291: return null; 292: } 293: return <Text dimColor> {input.task_id}</Text>; 294: }, 295: renderToolUseProgressMessage(progressMessages) { 296: const lastProgress = progressMessages[progressMessages.length - 1]; 297: const progressData = lastProgress?.data as { 298: taskDescription?: string; 299: taskType?: string; 300: } | undefined; 301: return <Box flexDirection="column"> 302: {progressData?.taskDescription && <Text>&nbsp;&nbsp;{progressData.taskDescription}</Text>} 303: <Text> 304: &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;Waiting for task{' '} 305: <Text dimColor>(esc to give additional instructions)</Text> 306: </Text> 307: </Box>; 308: }, 309: renderToolResultMessage(content, _, { 310: verbose, 311: theme 312: }) { 313: return <TaskOutputResultDisplay content={content} verbose={verbose} theme={theme} />; 314: }, 315: renderToolUseRejectedMessage() { 316: return <FallbackToolUseRejectedMessage />; 317: }, 318: renderToolUseErrorMessage(result, { 319: verbose 320: }) { 321: return <FallbackToolUseErrorMessage result={result} verbose={verbose} />; 322: } 323: } satisfies ToolDef<InputSchema, TaskOutputToolOutput>); 324: function TaskOutputResultDisplay(t0) { 325: const $ = _c(54); 326: const { 327: content, 328: verbose: t1, 329: theme 330: } = t0; 331: const verbose = t1 === undefined ? false : t1; 332: const expandShortcut = useShortcutDisplay("app:toggleTranscript", "Global", "ctrl+o"); 333: let t2; 334: if ($[0] !== content) { 335: t2 = typeof content === "string" ? jsonParse(content) : content; 336: $[0] = content; 337: $[1] = t2; 338: } else { 339: t2 = $[1]; 340: } 341: const result = t2; 342: if (!result.task) { 343: let t3; 344: if ($[2] === Symbol.for("react.memo_cache_sentinel")) { 345: t3 = <MessageResponse><Text dimColor={true}>No task output available</Text></MessageResponse>; 346: $[2] = t3; 347: } else { 348: t3 = $[2]; 349: } 350: return t3; 351: } 352: const { 353: task 354: } = result; 355: if (task.task_type === "local_bash") { 356: let t3; 357: if ($[3] !== task.error || $[4] !== task.output) { 358: t3 = { 359: stdout: task.output, 360: stderr: "", 361: isImage: false, 362: dangerouslyDisableSandbox: true, 363: returnCodeInterpretation: task.error 364: }; 365: $[3] = task.error; 366: $[4] = task.output; 367: $[5] = t3; 368: } else { 369: t3 = $[5]; 370: } 371: const bashOut = t3; 372: let t4; 373: if ($[6] !== bashOut || $[7] !== verbose) { 374: t4 = <BashToolResultMessage content={bashOut} verbose={verbose} />; 375: $[6] = bashOut; 376: $[7] = verbose; 377: $[8] = t4; 378: } else { 379: t4 = $[8]; 380: } 381: return t4; 382: } 383: if (task.task_type === "local_agent") { 384: const lineCount = task.result ? countCharInString(task.result, "\n") + 1 : 0; 385: if (result.retrieval_status === "success") { 386: if (verbose) { 387: let t3; 388: if ($[9] !== lineCount || $[10] !== task.description) { 389: t3 = <Text>{task.description} ({lineCount} lines)</Text>; 390: $[9] = lineCount; 391: $[10] = task.description; 392: $[11] = t3; 393: } else { 394: t3 = $[11]; 395: } 396: let t4; 397: if ($[12] !== task.prompt || $[13] !== theme) { 398: t4 = task.prompt && <AgentPromptDisplay prompt={task.prompt} theme={theme} dim={true} />; 399: $[12] = task.prompt; 400: $[13] = theme; 401: $[14] = t4; 402: } else { 403: t4 = $[14]; 404: } 405: let t5; 406: if ($[15] !== task.result || $[16] !== theme) { 407: t5 = task.result && <Box marginTop={1}><AgentResponseDisplay content={[{ 408: type: "text", 409: text: task.result 410: }]} theme={theme} /></Box>; 411: $[15] = task.result; 412: $[16] = theme; 413: $[17] = t5; 414: } else { 415: t5 = $[17]; 416: } 417: let t6; 418: if ($[18] !== task.error) { 419: t6 = task.error && <Box flexDirection="column" marginTop={1}><Text color="error" bold={true}>Error:</Text><Box paddingLeft={2}><Text color="error">{task.error}</Text></Box></Box>; 420: $[18] = task.error; 421: $[19] = t6; 422: } else { 423: t6 = $[19]; 424: } 425: let t7; 426: if ($[20] !== t4 || $[21] !== t5 || $[22] !== t6) { 427: t7 = <Box flexDirection="column" paddingLeft={2} marginTop={1}>{t4}{t5}{t6}</Box>; 428: $[20] = t4; 429: $[21] = t5; 430: $[22] = t6; 431: $[23] = t7; 432: } else { 433: t7 = $[23]; 434: } 435: let t8; 436: if ($[24] !== t3 || $[25] !== t7) { 437: t8 = <Box flexDirection="column">{t3}{t7}</Box>; 438: $[24] = t3; 439: $[25] = t7; 440: $[26] = t8; 441: } else { 442: t8 = $[26]; 443: } 444: return t8; 445: } 446: let t3; 447: if ($[27] !== expandShortcut) { 448: t3 = <MessageResponse><Text dimColor={true}>Read output ({expandShortcut} to expand)</Text></MessageResponse>; 449: $[27] = expandShortcut; 450: $[28] = t3; 451: } else { 452: t3 = $[28]; 453: } 454: return t3; 455: } 456: if (result.retrieval_status === "timeout" || task.status === "running") { 457: let t3; 458: if ($[29] === Symbol.for("react.memo_cache_sentinel")) { 459: t3 = <MessageResponse><Text dimColor={true}>Task is still running…</Text></MessageResponse>; 460: $[29] = t3; 461: } else { 462: t3 = $[29]; 463: } 464: return t3; 465: } 466: if (result.retrieval_status === "not_ready") { 467: let t3; 468: if ($[30] === Symbol.for("react.memo_cache_sentinel")) { 469: t3 = <MessageResponse><Text dimColor={true}>Task is still running…</Text></MessageResponse>; 470: $[30] = t3; 471: } else { 472: t3 = $[30]; 473: } 474: return t3; 475: } 476: let t3; 477: if ($[31] === Symbol.for("react.memo_cache_sentinel")) { 478: t3 = <MessageResponse><Text dimColor={true}>Task not ready</Text></MessageResponse>; 479: $[31] = t3; 480: } else { 481: t3 = $[31]; 482: } 483: return t3; 484: } 485: if (task.task_type === "remote_agent") { 486: let t3; 487: if ($[32] !== task.description || $[33] !== task.status) { 488: t3 = <Text>  {task.description} [{task.status}]</Text>; 489: $[32] = task.description; 490: $[33] = task.status; 491: $[34] = t3; 492: } else { 493: t3 = $[34]; 494: } 495: let t4; 496: if ($[35] !== task.output || $[36] !== verbose) { 497: t4 = task.output && verbose && <Box paddingLeft={4} marginTop={1}><Text>{task.output}</Text></Box>; 498: $[35] = task.output; 499: $[36] = verbose; 500: $[37] = t4; 501: } else { 502: t4 = $[37]; 503: } 504: let t5; 505: if ($[38] !== expandShortcut || $[39] !== task.output || $[40] !== verbose) { 506: t5 = !verbose && task.output && <Text dimColor={true}>{" "}({expandShortcut} to expand)</Text>; 507: $[38] = expandShortcut; 508: $[39] = task.output; 509: $[40] = verbose; 510: $[41] = t5; 511: } else { 512: t5 = $[41]; 513: } 514: let t6; 515: if ($[42] !== t3 || $[43] !== t4 || $[44] !== t5) { 516: t6 = <Box flexDirection="column">{t3}{t4}{t5}</Box>; 517: $[42] = t3; 518: $[43] = t4; 519: $[44] = t5; 520: $[45] = t6; 521: } else { 522: t6 = $[45]; 523: } 524: return t6; 525: } 526: let t3; 527: if ($[46] !== task.description || $[47] !== task.status) { 528: t3 = <Text>  {task.description} [{task.status}]</Text>; 529: $[46] = task.description; 530: $[47] = task.status; 531: $[48] = t3; 532: } else { 533: t3 = $[48]; 534: } 535: let t4; 536: if ($[49] !== task.output) { 537: t4 = task.output && <Box paddingLeft={4}><Text>{task.output.slice(0, 500)}</Text></Box>; 538: $[49] = task.output; 539: $[50] = t4; 540: } else { 541: t4 = $[50]; 542: } 543: let t5; 544: if ($[51] !== t3 || $[52] !== t4) { 545: t5 = <Box flexDirection="column">{t3}{t4}</Box>; 546: $[51] = t3; 547: $[52] = t4; 548: $[53] = t5; 549: } else { 550: t5 = $[53]; 551: } 552: return t5; 553: } 554: export default TaskOutputTool;

File: src/tools/TaskStopTool/prompt.ts

typescript 1: export const TASK_STOP_TOOL_NAME = 'TaskStop' 2: export const DESCRIPTION = ` 3: - Stops a running background task by its ID 4: - Takes a task_id parameter identifying the task to stop 5: - Returns a success or failure status 6: - Use this tool when you need to terminate a long-running task 7: `

File: src/tools/TaskStopTool/TaskStopTool.ts

typescript 1: import { z } from 'zod/v4' 2: import type { TaskStateBase } from '../../Task.js' 3: import { buildTool, type ToolDef } from '../../Tool.js' 4: import { stopTask } from '../../tasks/stopTask.js' 5: import { lazySchema } from '../../utils/lazySchema.js' 6: import { jsonStringify } from '../../utils/slowOperations.js' 7: import { DESCRIPTION, TASK_STOP_TOOL_NAME } from './prompt.js' 8: import { renderToolResultMessage, renderToolUseMessage } from './UI.js' 9: const inputSchema = lazySchema(() => 10: z.strictObject({ 11: task_id: z 12: .string() 13: .optional() 14: .describe('The ID of the background task to stop'), 15: shell_id: z.string().optional().describe('Deprecated: use task_id instead'), 16: }), 17: ) 18: type InputSchema = ReturnType<typeof inputSchema> 19: const outputSchema = lazySchema(() => 20: z.object({ 21: message: z.string().describe('Status message about the operation'), 22: task_id: z.string().describe('The ID of the task that was stopped'), 23: task_type: z.string().describe('The type of the task that was stopped'), 24: command: z 25: .string() 26: .optional() 27: .describe('The command or description of the stopped task'), 28: }), 29: ) 30: type OutputSchema = ReturnType<typeof outputSchema> 31: export type Output = z.infer<OutputSchema> 32: export const TaskStopTool = buildTool({ 33: name: TASK_STOP_TOOL_NAME, 34: searchHint: 'kill a running background task', 35: aliases: ['KillShell'], 36: maxResultSizeChars: 100_000, 37: userFacingName: () => (process.env.USER_TYPE === 'ant' ? '' : 'Stop Task'), 38: get inputSchema(): InputSchema { 39: return inputSchema() 40: }, 41: get outputSchema(): OutputSchema { 42: return outputSchema() 43: }, 44: shouldDefer: true, 45: isConcurrencySafe() { 46: return true 47: }, 48: toAutoClassifierInput(input) { 49: return input.task_id ?? input.shell_id ?? '' 50: }, 51: async validateInput({ task_id, shell_id }, { getAppState }) { 52: // Support both task_id and shell_id (deprecated KillShell compat) 53: const id = task_id ?? shell_id 54: if (!id) { 55: return { 56: result: false, 57: message: 'Missing required parameter: task_id', 58: errorCode: 1, 59: } 60: } 61: const appState = getAppState() 62: const task = appState.tasks?.[id] as TaskStateBase | undefined 63: if (!task) { 64: return { 65: result: false, 66: message: `No task found with ID: ${id}`, 67: errorCode: 1, 68: } 69: } 70: if (task.status !== 'running') { 71: return { 72: result: false, 73: message: `Task ${id} is not running (status: ${task.status})`, 74: errorCode: 3, 75: } 76: } 77: return { result: true } 78: }, 79: async description() { 80: return `Stop a running background task by ID` 81: }, 82: async prompt() { 83: return DESCRIPTION 84: }, 85: mapToolResultToToolResultBlockParam(output, toolUseID) { 86: return { 87: tool_use_id: toolUseID, 88: type: 'tool_result', 89: content: jsonStringify(output), 90: } 91: }, 92: renderToolUseMessage, 93: renderToolResultMessage, 94: async call( 95: { task_id, shell_id }, 96: { getAppState, setAppState, abortController }, 97: ) { 98: const id = task_id ?? shell_id 99: if (!id) { 100: throw new Error('Missing required parameter: task_id') 101: } 102: const result = await stopTask(id, { 103: getAppState, 104: setAppState, 105: }) 106: return { 107: data: { 108: message: `Successfully stopped task: ${result.taskId} (${result.command})`, 109: task_id: result.taskId, 110: task_type: result.taskType, 111: command: result.command, 112: }, 113: } 114: }, 115: } satisfies ToolDef<InputSchema, Output>)

File: src/tools/TaskStopTool/UI.tsx

typescript 1: import React from 'react'; 2: import { MessageResponse } from '../../components/MessageResponse.js'; 3: import { stringWidth } from '../../ink/stringWidth.js'; 4: import { Text } from '../../ink.js'; 5: import { truncateToWidthNoEllipsis } from '../../utils/format.js'; 6: import type { Output } from './TaskStopTool.js'; 7: export function renderToolUseMessage(): React.ReactNode { 8: return ''; 9: } 10: const MAX_COMMAND_DISPLAY_LINES = 2; 11: const MAX_COMMAND_DISPLAY_CHARS = 160; 12: function truncateCommand(command: string): string { 13: const lines = command.split('\n'); 14: let truncated = command; 15: if (lines.length > MAX_COMMAND_DISPLAY_LINES) { 16: truncated = lines.slice(0, MAX_COMMAND_DISPLAY_LINES).join('\n'); 17: } 18: if (stringWidth(truncated) > MAX_COMMAND_DISPLAY_CHARS) { 19: truncated = truncateToWidthNoEllipsis(truncated, MAX_COMMAND_DISPLAY_CHARS); 20: } 21: return truncated.trim(); 22: } 23: export function renderToolResultMessage(output: Output, _progressMessagesForMessage: unknown[], { 24: verbose 25: }: { 26: verbose: boolean; 27: }): React.ReactNode { 28: if ("external" === 'ant') { 29: return null; 30: } 31: const rawCommand = output.command ?? ''; 32: const command = verbose ? rawCommand : truncateCommand(rawCommand); 33: const suffix = command !== rawCommand ? '… · stopped' : ' · stopped'; 34: return <MessageResponse> 35: <Text> 36: {command} 37: {suffix} 38: </Text> 39: </MessageResponse>; 40: }

File: src/tools/TaskUpdateTool/constants.ts

typescript 1: export const TASK_UPDATE_TOOL_NAME = 'TaskUpdate'

File: src/tools/TaskUpdateTool/prompt.ts

typescript 1: export const DESCRIPTION = 'Update a task in the task list' 2: export const PROMPT = `Use this tool to update a task in the task list. 3: ## When to Use This Tool 4: **Mark tasks as resolved:** 5: - When you have completed the work described in a task 6: - When a task is no longer needed or has been superseded 7: - IMPORTANT: Always mark your assigned tasks as resolved when you finish them 8: - After resolving, call TaskList to find your next task 9: - ONLY mark a task as completed when you have FULLY accomplished it 10: - If you encounter errors, blockers, or cannot finish, keep the task as in_progress 11: - When blocked, create a new task describing what needs to be resolved 12: - Never mark a task as completed if: 13: - Tests are failing 14: - Implementation is partial 15: - You encountered unresolved errors 16: - You couldn't find necessary files or dependencies 17: **Delete tasks:** 18: - When a task is no longer relevant or was created in error 19: - Setting status to \`deleted\` permanently removes the task 20: **Update task details:** 21: - When requirements change or become clearer 22: - When establishing dependencies between tasks 23: ## Fields You Can Update 24: - **status**: The task status (see Status Workflow below) 25: - **subject**: Change the task title (imperative form, e.g., "Run tests") 26: - **description**: Change the task description 27: - **activeForm**: Present continuous form shown in spinner when in_progress (e.g., "Running tests") 28: - **owner**: Change the task owner (agent name) 29: - **metadata**: Merge metadata keys into the task (set a key to null to delete it) 30: - **addBlocks**: Mark tasks that cannot start until this one completes 31: - **addBlockedBy**: Mark tasks that must complete before this one can start 32: ## Status Workflow 33: Status progresses: \`pending\` → \`in_progress\` → \`completed\` 34: Use \`deleted\` to permanently remove a task. 35: ## Staleness 36: Make sure to read a task's latest state using \`TaskGet\` before updating it. 37: ## Examples 38: Mark task as in progress when starting work: 39: \`\`\`json 40: {"taskId": "1", "status": "in_progress"} 41: \`\`\` 42: Mark task as completed after finishing work: 43: \`\`\`json 44: {"taskId": "1", "status": "completed"} 45: \`\`\` 46: Delete a task: 47: \`\`\`json 48: {"taskId": "1", "status": "deleted"} 49: \`\`\` 50: Claim a task by setting owner: 51: \`\`\`json 52: {"taskId": "1", "owner": "my-name"} 53: \`\`\` 54: Set up task dependencies: 55: \`\`\`json 56: {"taskId": "2", "addBlockedBy": ["1"]} 57: \`\`\` 58: `

File: src/tools/TaskUpdateTool/TaskUpdateTool.ts

typescript 1: import { feature } from 'bun:bundle' 2: import { z } from 'zod/v4' 3: import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js' 4: import { buildTool, type ToolDef } from '../../Tool.js' 5: import { isAgentSwarmsEnabled } from '../../utils/agentSwarmsEnabled.js' 6: import { 7: executeTaskCompletedHooks, 8: getTaskCompletedHookMessage, 9: } from '../../utils/hooks.js' 10: import { lazySchema } from '../../utils/lazySchema.js' 11: import { 12: blockTask, 13: deleteTask, 14: getTask, 15: getTaskListId, 16: isTodoV2Enabled, 17: listTasks, 18: type TaskStatus, 19: TaskStatusSchema, 20: updateTask, 21: } from '../../utils/tasks.js' 22: import { 23: getAgentId, 24: getAgentName, 25: getTeammateColor, 26: getTeamName, 27: } from '../../utils/teammate.js' 28: import { writeToMailbox } from '../../utils/teammateMailbox.js' 29: import { VERIFICATION_AGENT_TYPE } from '../AgentTool/constants.js' 30: import { TASK_UPDATE_TOOL_NAME } from './constants.js' 31: import { DESCRIPTION, PROMPT } from './prompt.js' 32: const inputSchema = lazySchema(() => { 33: const TaskUpdateStatusSchema = TaskStatusSchema().or(z.literal('deleted')) 34: return z.strictObject({ 35: taskId: z.string().describe('The ID of the task to update'), 36: subject: z.string().optional().describe('New subject for the task'), 37: description: z.string().optional().describe('New description for the task'), 38: activeForm: z 39: .string() 40: .optional() 41: .describe( 42: 'Present continuous form shown in spinner when in_progress (e.g., "Running tests")', 43: ), 44: status: TaskUpdateStatusSchema.optional().describe( 45: 'New status for the task', 46: ), 47: addBlocks: z 48: .array(z.string()) 49: .optional() 50: .describe('Task IDs that this task blocks'), 51: addBlockedBy: z 52: .array(z.string()) 53: .optional() 54: .describe('Task IDs that block this task'), 55: owner: z.string().optional().describe('New owner for the task'), 56: metadata: z 57: .record(z.string(), z.unknown()) 58: .optional() 59: .describe( 60: 'Metadata keys to merge into the task. Set a key to null to delete it.', 61: ), 62: }) 63: }) 64: type InputSchema = ReturnType<typeof inputSchema> 65: const outputSchema = lazySchema(() => 66: z.object({ 67: success: z.boolean(), 68: taskId: z.string(), 69: updatedFields: z.array(z.string()), 70: error: z.string().optional(), 71: statusChange: z 72: .object({ 73: from: z.string(), 74: to: z.string(), 75: }) 76: .optional(), 77: verificationNudgeNeeded: z.boolean().optional(), 78: }), 79: ) 80: type OutputSchema = ReturnType<typeof outputSchema> 81: export type Output = z.infer<OutputSchema> 82: export const TaskUpdateTool = buildTool({ 83: name: TASK_UPDATE_TOOL_NAME, 84: searchHint: 'update a task', 85: maxResultSizeChars: 100_000, 86: async description() { 87: return DESCRIPTION 88: }, 89: async prompt() { 90: return PROMPT 91: }, 92: get inputSchema(): InputSchema { 93: return inputSchema() 94: }, 95: get outputSchema(): OutputSchema { 96: return outputSchema() 97: }, 98: userFacingName() { 99: return 'TaskUpdate' 100: }, 101: shouldDefer: true, 102: isEnabled() { 103: return isTodoV2Enabled() 104: }, 105: isConcurrencySafe() { 106: return true 107: }, 108: toAutoClassifierInput(input) { 109: const parts = [input.taskId] 110: if (input.status) parts.push(input.status) 111: if (input.subject) parts.push(input.subject) 112: return parts.join(' ') 113: }, 114: renderToolUseMessage() { 115: return null 116: }, 117: async call( 118: { 119: taskId, 120: subject, 121: description, 122: activeForm, 123: status, 124: owner, 125: addBlocks, 126: addBlockedBy, 127: metadata, 128: }, 129: context, 130: ) { 131: const taskListId = getTaskListId() 132: context.setAppState(prev => { 133: if (prev.expandedView === 'tasks') return prev 134: return { ...prev, expandedView: 'tasks' as const } 135: }) 136: const existingTask = await getTask(taskListId, taskId) 137: if (!existingTask) { 138: return { 139: data: { 140: success: false, 141: taskId, 142: updatedFields: [], 143: error: 'Task not found', 144: }, 145: } 146: } 147: const updatedFields: string[] = [] 148: const updates: { 149: subject?: string 150: description?: string 151: activeForm?: string 152: status?: TaskStatus 153: owner?: string 154: metadata?: Record<string, unknown> 155: } = {} 156: if (subject !== undefined && subject !== existingTask.subject) { 157: updates.subject = subject 158: updatedFields.push('subject') 159: } 160: if (description !== undefined && description !== existingTask.description) { 161: updates.description = description 162: updatedFields.push('description') 163: } 164: if (activeForm !== undefined && activeForm !== existingTask.activeForm) { 165: updates.activeForm = activeForm 166: updatedFields.push('activeForm') 167: } 168: if (owner !== undefined && owner !== existingTask.owner) { 169: updates.owner = owner 170: updatedFields.push('owner') 171: } 172: if ( 173: isAgentSwarmsEnabled() && 174: status === 'in_progress' && 175: owner === undefined && 176: !existingTask.owner 177: ) { 178: const agentName = getAgentName() 179: if (agentName) { 180: updates.owner = agentName 181: updatedFields.push('owner') 182: } 183: } 184: if (metadata !== undefined) { 185: const merged = { ...(existingTask.metadata ?? {}) } 186: for (const [key, value] of Object.entries(metadata)) { 187: if (value === null) { 188: delete merged[key] 189: } else { 190: merged[key] = value 191: } 192: } 193: updates.metadata = merged 194: updatedFields.push('metadata') 195: } 196: if (status !== undefined) { 197: if (status === 'deleted') { 198: const deleted = await deleteTask(taskListId, taskId) 199: return { 200: data: { 201: success: deleted, 202: taskId, 203: updatedFields: deleted ? ['deleted'] : [], 204: error: deleted ? undefined : 'Failed to delete task', 205: statusChange: deleted 206: ? { from: existingTask.status, to: 'deleted' } 207: : undefined, 208: }, 209: } 210: } 211: if (status !== existingTask.status) { 212: if (status === 'completed') { 213: const blockingErrors: string[] = [] 214: const generator = executeTaskCompletedHooks( 215: taskId, 216: existingTask.subject, 217: existingTask.description, 218: getAgentName(), 219: getTeamName(), 220: undefined, 221: context?.abortController?.signal, 222: undefined, 223: context, 224: ) 225: for await (const result of generator) { 226: if (result.blockingError) { 227: blockingErrors.push( 228: getTaskCompletedHookMessage(result.blockingError), 229: ) 230: } 231: } 232: if (blockingErrors.length > 0) { 233: return { 234: data: { 235: success: false, 236: taskId, 237: updatedFields: [], 238: error: blockingErrors.join('\n'), 239: }, 240: } 241: } 242: } 243: updates.status = status 244: updatedFields.push('status') 245: } 246: } 247: if (Object.keys(updates).length > 0) { 248: await updateTask(taskListId, taskId, updates) 249: } 250: if (updates.owner && isAgentSwarmsEnabled()) { 251: const senderName = getAgentName() || 'team-lead' 252: const senderColor = getTeammateColor() 253: const assignmentMessage = JSON.stringify({ 254: type: 'task_assignment', 255: taskId, 256: subject: existingTask.subject, 257: description: existingTask.description, 258: assignedBy: senderName, 259: timestamp: new Date().toISOString(), 260: }) 261: await writeToMailbox( 262: updates.owner, 263: { 264: from: senderName, 265: text: assignmentMessage, 266: timestamp: new Date().toISOString(), 267: color: senderColor, 268: }, 269: taskListId, 270: ) 271: } 272: if (addBlocks && addBlocks.length > 0) { 273: const newBlocks = addBlocks.filter( 274: id => !existingTask.blocks.includes(id), 275: ) 276: for (const blockId of newBlocks) { 277: await blockTask(taskListId, taskId, blockId) 278: } 279: if (newBlocks.length > 0) { 280: updatedFields.push('blocks') 281: } 282: } 283: if (addBlockedBy && addBlockedBy.length > 0) { 284: const newBlockedBy = addBlockedBy.filter( 285: id => !existingTask.blockedBy.includes(id), 286: ) 287: for (const blockerId of newBlockedBy) { 288: await blockTask(taskListId, blockerId, taskId) 289: } 290: if (newBlockedBy.length > 0) { 291: updatedFields.push('blockedBy') 292: } 293: } 294: let verificationNudgeNeeded = false 295: if ( 296: feature('VERIFICATION_AGENT') && 297: getFeatureValue_CACHED_MAY_BE_STALE('tengu_hive_evidence', false) && 298: !context.agentId && 299: updates.status === 'completed' 300: ) { 301: const allTasks = await listTasks(taskListId) 302: const allDone = allTasks.every(t => t.status === 'completed') 303: if ( 304: allDone && 305: allTasks.length >= 3 && 306: !allTasks.some(t => /verif/i.test(t.subject)) 307: ) { 308: verificationNudgeNeeded = true 309: } 310: } 311: return { 312: data: { 313: success: true, 314: taskId, 315: updatedFields, 316: statusChange: 317: updates.status !== undefined 318: ? { from: existingTask.status, to: updates.status } 319: : undefined, 320: verificationNudgeNeeded, 321: }, 322: } 323: }, 324: mapToolResultToToolResultBlockParam(content, toolUseID) { 325: const { 326: success, 327: taskId, 328: updatedFields, 329: error, 330: statusChange, 331: verificationNudgeNeeded, 332: } = content as Output 333: if (!success) { 334: return { 335: tool_use_id: toolUseID, 336: type: 'tool_result', 337: content: error || `Task #${taskId} not found`, 338: } 339: } 340: let resultContent = `Updated task #${taskId} ${updatedFields.join(', ')}` 341: if ( 342: statusChange?.to === 'completed' && 343: getAgentId() && 344: isAgentSwarmsEnabled() 345: ) { 346: resultContent += 347: '\n\nTask completed. Call TaskList now to find your next available task or see if your work unblocked others.' 348: } 349: if (verificationNudgeNeeded) { 350: resultContent += `\n\nNOTE: You just closed out 3+ tasks and none of them was a verification step. Before writing your final summary, spawn the verification agent (subagent_type="${VERIFICATION_AGENT_TYPE}"). You cannot self-assign PARTIAL by listing caveats in your summary — only the verifier issues a verdict.` 351: } 352: return { 353: tool_use_id: toolUseID, 354: type: 'tool_result', 355: content: resultContent, 356: } 357: }, 358: } satisfies ToolDef<InputSchema, Output>)

File: src/tools/TeamCreateTool/constants.ts

typescript 1: export const TEAM_CREATE_TOOL_NAME = 'TeamCreate'

File: src/tools/TeamCreateTool/prompt.ts

typescript 1: export function getPrompt(): string { 2: return ` 3: # TeamCreate 4: ## When to Use 5: Use this tool proactively whenever: 6: - The user explicitly asks to use a team, swarm, or group of agents 7: - The user mentions wanting agents to work together, coordinate, or collaborate 8: - A task is complex enough that it would benefit from parallel work by multiple agents (e.g., building a full-stack feature with frontend and backend work, refactoring a codebase while keeping tests passing, implementing a multi-step project with research, planning, and coding phases) 9: When in doubt about whether a task warrants a team, prefer spawning a team. 10: ## Choosing Agent Types for Teammates 11: When spawning teammates via the Agent tool, choose the \`subagent_type\` based on what tools the agent needs for its task. Each agent type has a different set of available tools — match the agent to the work: 12: - **Read-only agents** (e.g., Explore, Plan) cannot edit or write files. Only assign them research, search, or planning tasks. Never assign them implementation work. 13: - **Full-capability agents** (e.g., general-purpose) have access to all tools including file editing, writing, and bash. Use these for tasks that require making changes. 14: - **Custom agents** defined in \`.claude/agents/\` may have their own tool restrictions. Check their descriptions to understand what they can and cannot do. 15: Always review the agent type descriptions and their available tools listed in the Agent tool prompt before selecting a \`subagent_type\` for a teammate. 16: Create a new team to coordinate multiple agents working on a project. Teams have a 1:1 correspondence with task lists (Team = TaskList). 17: \`\`\` 18: { 19: "team_name": "my-project", 20: "description": "Working on feature X" 21: } 22: \`\`\` 23: This creates: 24: - A team file at \`~/.claude/teams/{team-name}/config.json\` 25: - A corresponding task list directory at \`~/.claude/tasks/{team-name}/\` 26: ## Team Workflow 27: 1. **Create a team** with TeamCreate - this creates both the team and its task list 28: 2. **Create tasks** using the Task tools (TaskCreate, TaskList, etc.) - they automatically use the team's task list 29: 3. **Spawn teammates** using the Agent tool with \`team_name\` and \`name\` parameters to create teammates that join the team 30: 4. **Assign tasks** using TaskUpdate with \`owner\` to give tasks to idle teammates 31: 5. **Teammates work on assigned tasks** and mark them completed via TaskUpdate 32: 6. **Teammates go idle between turns** - after each turn, teammates automatically go idle and send a notification. IMPORTANT: Be patient with idle teammates! Don't comment on their idleness until it actually impacts your work. 33: 7. **Shutdown your team** - when the task is completed, gracefully shut down your teammates via SendMessage with \`message: {type: "shutdown_request"}\`. 34: ## Task Ownership 35: Tasks are assigned using TaskUpdate with the \`owner\` parameter. Any agent can set or change task ownership via TaskUpdate. 36: ## Automatic Message Delivery 37: **IMPORTANT**: Messages from teammates are automatically delivered to you. You do NOT need to manually check your inbox. 38: When you spawn teammates: 39: - They will send you messages when they complete tasks or need help 40: - These messages appear automatically as new conversation turns (like user messages) 41: - If you're busy (mid-turn), messages are queued and delivered when your turn ends 42: - The UI shows a brief notification with the sender's name when messages are waiting 43: Messages will be delivered automatically. 44: When reporting on teammate messages, you do NOT need to quote the original message—it's already rendered to the user. 45: ## Teammate Idle State 46: Teammates go idle after every turn—this is completely normal and expected. A teammate going idle immediately after sending you a message does NOT mean they are done or unavailable. Idle simply means they are waiting for input. 47: - **Idle teammates can receive messages.** Sending a message to an idle teammate wakes them up and they will process it normally. 48: - **Idle notifications are automatic.** The system sends an idle notification whenever a teammate's turn ends. You do not need to react to idle notifications unless you want to assign new work or send a follow-up message. 49: - **Do not treat idle as an error.** A teammate sending a message and then going idle is the normal flow—they sent their message and are now waiting for a response. 50: - **Peer DM visibility.** When a teammate sends a DM to another teammate, a brief summary is included in their idle notification. This gives you visibility into peer collaboration without the full message content. You do not need to respond to these summaries — they are informational. 51: ## Discovering Team Members 52: Teammates can read the team config file to discover other team members: 53: - **Team config location**: \`~/.claude/teams/{team-name}/config.json\` 54: The config file contains a \`members\` array with each teammate's: 55: - \`name\`: Human-readable name (**always use this** for messaging and task assignment) 56: - \`agentId\`: Unique identifier (for reference only - do not use for communication) 57: - \`agentType\`: Role/type of the agent 58: **IMPORTANT**: Always refer to teammates by their NAME (e.g., "team-lead", "researcher", "tester"). Names are used for: 59: - \`to\` when sending messages 60: - Identifying task owners 61: Example of reading team config: 62: \`\`\` 63: Use the Read tool to read ~/.claude/teams/{team-name}/config.json 64: \`\`\` 65: ## Task List Coordination 66: Teams share a task list that all teammates can access at \`~/.claude/tasks/{team-name}/\`. 67: Teammates should: 68: 1. Check TaskList periodically, **especially after completing each task**, to find available work or see newly unblocked tasks 69: 2. Claim unassigned, unblocked tasks with TaskUpdate (set \`owner\` to your name). **Prefer tasks in ID order** (lowest ID first) when multiple tasks are available, as earlier tasks often set up context for later ones 70: 3. Create new tasks with \`TaskCreate\` when identifying additional work 71: 4. Mark tasks as completed with \`TaskUpdate\` when done, then check TaskList for next work 72: 5. Coordinate with other teammates by reading the task list status 73: 6. If all available tasks are blocked, notify the team lead or help resolve blocking tasks 74: **IMPORTANT notes for communication with your team**: 75: - Do not use terminal tools to view your team's activity; always send a message to your teammates (and remember, refer to them by name). 76: - Your team cannot hear you if you do not use the SendMessage tool. Always send a message to your teammates if you are responding to them. 77: - Do NOT send structured JSON status messages like \`{"type":"idle",...}\` or \`{"type":"task_completed",...}\`. Just communicate in plain text when you need to message teammates. 78: - Use TaskUpdate to mark tasks completed. 79: - If you are an agent in the team, the system will automatically send idle notifications to the team lead when you stop. 80: `.trim() 81: }

File: src/tools/TeamCreateTool/TeamCreateTool.ts

typescript 1: import { z } from 'zod/v4' 2: import { getSessionId } from '../../bootstrap/state.js' 3: import { logEvent } from '../../services/analytics/index.js' 4: import type { AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS } from '../../services/analytics/metadata.js' 5: import type { Tool } from '../../Tool.js' 6: import { buildTool, type ToolDef } from '../../Tool.js' 7: import { formatAgentId } from '../../utils/agentId.js' 8: import { isAgentSwarmsEnabled } from '../../utils/agentSwarmsEnabled.js' 9: import { getCwd } from '../../utils/cwd.js' 10: import { lazySchema } from '../../utils/lazySchema.js' 11: import { 12: getDefaultMainLoopModel, 13: parseUserSpecifiedModel, 14: } from '../../utils/model/model.js' 15: import { jsonStringify } from '../../utils/slowOperations.js' 16: import { getResolvedTeammateMode } from '../../utils/swarm/backends/registry.js' 17: import { TEAM_LEAD_NAME } from '../../utils/swarm/constants.js' 18: import type { TeamFile } from '../../utils/swarm/teamHelpers.js' 19: import { 20: getTeamFilePath, 21: readTeamFile, 22: registerTeamForSessionCleanup, 23: sanitizeName, 24: writeTeamFileAsync, 25: } from '../../utils/swarm/teamHelpers.js' 26: import { assignTeammateColor } from '../../utils/swarm/teammateLayoutManager.js' 27: import { 28: ensureTasksDir, 29: resetTaskList, 30: setLeaderTeamName, 31: } from '../../utils/tasks.js' 32: import { generateWordSlug } from '../../utils/words.js' 33: import { TEAM_CREATE_TOOL_NAME } from './constants.js' 34: import { getPrompt } from './prompt.js' 35: import { renderToolUseMessage } from './UI.js' 36: const inputSchema = lazySchema(() => 37: z.strictObject({ 38: team_name: z.string().describe('Name for the new team to create.'), 39: description: z.string().optional().describe('Team description/purpose.'), 40: agent_type: z 41: .string() 42: .optional() 43: .describe( 44: 'Type/role of the team lead (e.g., "researcher", "test-runner"). ' + 45: 'Used for team file and inter-agent coordination.', 46: ), 47: }), 48: ) 49: type InputSchema = ReturnType<typeof inputSchema> 50: export type Output = { 51: team_name: string 52: team_file_path: string 53: lead_agent_id: string 54: } 55: export type Input = z.infer<InputSchema> 56: function generateUniqueTeamName(providedName: string): string { 57: if (!readTeamFile(providedName)) { 58: return providedName 59: } 60: return generateWordSlug() 61: } 62: export const TeamCreateTool: Tool<InputSchema, Output> = buildTool({ 63: name: TEAM_CREATE_TOOL_NAME, 64: searchHint: 'create a multi-agent swarm team', 65: maxResultSizeChars: 100_000, 66: shouldDefer: true, 67: userFacingName() { 68: return '' 69: }, 70: get inputSchema(): InputSchema { 71: return inputSchema() 72: }, 73: isEnabled() { 74: return isAgentSwarmsEnabled() 75: }, 76: toAutoClassifierInput(input) { 77: return input.team_name 78: }, 79: async validateInput(input, _context) { 80: if (!input.team_name || input.team_name.trim().length === 0) { 81: return { 82: result: false, 83: message: 'team_name is required for TeamCreate', 84: errorCode: 9, 85: } 86: } 87: return { result: true } 88: }, 89: async description() { 90: return 'Create a new team for coordinating multiple agents' 91: }, 92: async prompt() { 93: return getPrompt() 94: }, 95: mapToolResultToToolResultBlockParam(data, toolUseID) { 96: return { 97: tool_use_id: toolUseID, 98: type: 'tool_result' as const, 99: content: [ 100: { 101: type: 'text' as const, 102: text: jsonStringify(data), 103: }, 104: ], 105: } 106: }, 107: async call(input, context) { 108: const { setAppState, getAppState } = context 109: const { team_name, description: _description, agent_type } = input 110: const appState = getAppState() 111: const existingTeam = appState.teamContext?.teamName 112: if (existingTeam) { 113: throw new Error( 114: `Already leading team "${existingTeam}". A leader can only manage one team at a time. Use TeamDelete to end the current team before creating a new one.`, 115: ) 116: } 117: const finalTeamName = generateUniqueTeamName(team_name) 118: const leadAgentId = formatAgentId(TEAM_LEAD_NAME, finalTeamName) 119: const leadAgentType = agent_type || TEAM_LEAD_NAME 120: const leadModel = parseUserSpecifiedModel( 121: appState.mainLoopModelForSession ?? 122: appState.mainLoopModel ?? 123: getDefaultMainLoopModel(), 124: ) 125: const teamFilePath = getTeamFilePath(finalTeamName) 126: const teamFile: TeamFile = { 127: name: finalTeamName, 128: description: _description, 129: createdAt: Date.now(), 130: leadAgentId, 131: leadSessionId: getSessionId(), 132: members: [ 133: { 134: agentId: leadAgentId, 135: name: TEAM_LEAD_NAME, 136: agentType: leadAgentType, 137: model: leadModel, 138: joinedAt: Date.now(), 139: tmuxPaneId: '', 140: cwd: getCwd(), 141: subscriptions: [], 142: }, 143: ], 144: } 145: await writeTeamFileAsync(finalTeamName, teamFile) 146: // Track for session-end cleanup — teams were left on disk forever 147: // unless explicitly TeamDelete'd (gh-32730). 148: registerTeamForSessionCleanup(finalTeamName) 149: const taskListId = sanitizeName(finalTeamName) 150: await resetTaskList(taskListId) 151: await ensureTasksDir(taskListId) 152: setLeaderTeamName(sanitizeName(finalTeamName)) 153: setAppState(prev => ({ 154: ...prev, 155: teamContext: { 156: teamName: finalTeamName, 157: teamFilePath, 158: leadAgentId, 159: teammates: { 160: [leadAgentId]: { 161: name: TEAM_LEAD_NAME, 162: agentType: leadAgentType, 163: color: assignTeammateColor(leadAgentId), 164: tmuxSessionName: '', 165: tmuxPaneId: '', 166: cwd: getCwd(), 167: spawnedAt: Date.now(), 168: }, 169: }, 170: }, 171: })) 172: logEvent('tengu_team_created', { 173: team_name: 174: finalTeamName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 175: teammate_count: 1, 176: lead_agent_type: 177: leadAgentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 178: teammate_mode: 179: getResolvedTeammateMode() as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 180: }) 181: return { 182: data: { 183: team_name: finalTeamName, 184: team_file_path: teamFilePath, 185: lead_agent_id: leadAgentId, 186: }, 187: } 188: }, 189: renderToolUseMessage, 190: } satisfies ToolDef<InputSchema, Output>)

File: src/tools/TeamCreateTool/UI.tsx

typescript 1: import React from 'react'; 2: import type { Input } from './TeamCreateTool.js'; 3: export function renderToolUseMessage(input: Partial<Input>): React.ReactNode { 4: return `create team: ${input.team_name}`; 5: }

File: src/tools/TeamDeleteTool/constants.ts

typescript 1: export const TEAM_DELETE_TOOL_NAME = 'TeamDelete'

File: src/tools/TeamDeleteTool/prompt.ts

typescript 1: export function getPrompt(): string { 2: return ` 3: # TeamDelete 4: Remove team and task directories when the swarm work is complete. 5: This operation: 6: - Removes the team directory (\`~/.claude/teams/{team-name}/\`) 7: - Removes the task directory (\`~/.claude/tasks/{team-name}/\`) 8: - Clears team context from the current session 9: **IMPORTANT**: TeamDelete will fail if the team still has active members. Gracefully terminate teammates first, then call TeamDelete after all teammates have shut down. 10: Use this when all teammates have finished their work and you want to clean up the team resources. The team name is automatically determined from the current session's team context. 11: `.trim() 12: }

File: src/tools/TeamDeleteTool/TeamDeleteTool.ts

typescript 1: import { z } from 'zod/v4' 2: import { logEvent } from '../../services/analytics/index.js' 3: import type { AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS } from '../../services/analytics/metadata.js' 4: import type { Tool } from '../../Tool.js' 5: import { buildTool, type ToolDef } from '../../Tool.js' 6: import { isAgentSwarmsEnabled } from '../../utils/agentSwarmsEnabled.js' 7: import { lazySchema } from '../../utils/lazySchema.js' 8: import { jsonStringify } from '../../utils/slowOperations.js' 9: import { TEAM_LEAD_NAME } from '../../utils/swarm/constants.js' 10: import { 11: cleanupTeamDirectories, 12: readTeamFile, 13: unregisterTeamForSessionCleanup, 14: } from '../../utils/swarm/teamHelpers.js' 15: import { clearTeammateColors } from '../../utils/swarm/teammateLayoutManager.js' 16: import { clearLeaderTeamName } from '../../utils/tasks.js' 17: import { TEAM_DELETE_TOOL_NAME } from './constants.js' 18: import { getPrompt } from './prompt.js' 19: import { renderToolResultMessage, renderToolUseMessage } from './UI.js' 20: const inputSchema = lazySchema(() => z.strictObject({})) 21: type InputSchema = ReturnType<typeof inputSchema> 22: export type Output = { 23: success: boolean 24: message: string 25: team_name?: string 26: } 27: export type Input = z.infer<InputSchema> 28: export const TeamDeleteTool: Tool<InputSchema, Output> = buildTool({ 29: name: TEAM_DELETE_TOOL_NAME, 30: searchHint: 'disband a swarm team and clean up', 31: maxResultSizeChars: 100_000, 32: shouldDefer: true, 33: userFacingName() { 34: return '' 35: }, 36: get inputSchema(): InputSchema { 37: return inputSchema() 38: }, 39: isEnabled() { 40: return isAgentSwarmsEnabled() 41: }, 42: async description() { 43: return 'Clean up team and task directories when the swarm is complete' 44: }, 45: async prompt() { 46: return getPrompt() 47: }, 48: mapToolResultToToolResultBlockParam(data, toolUseID) { 49: return { 50: tool_use_id: toolUseID, 51: type: 'tool_result' as const, 52: content: [ 53: { 54: type: 'text' as const, 55: text: jsonStringify(data), 56: }, 57: ], 58: } 59: }, 60: async call(_input, context) { 61: const { setAppState, getAppState } = context 62: const appState = getAppState() 63: const teamName = appState.teamContext?.teamName 64: if (teamName) { 65: const teamFile = readTeamFile(teamName) 66: if (teamFile) { 67: const nonLeadMembers = teamFile.members.filter( 68: m => m.name !== TEAM_LEAD_NAME, 69: ) 70: const activeMembers = nonLeadMembers.filter(m => m.isActive !== false) 71: if (activeMembers.length > 0) { 72: const memberNames = activeMembers.map(m => m.name).join(', ') 73: return { 74: data: { 75: success: false, 76: message: `Cannot cleanup team with ${activeMembers.length} active member(s): ${memberNames}. Use requestShutdown to gracefully terminate teammates first.`, 77: team_name: teamName, 78: }, 79: } 80: } 81: } 82: await cleanupTeamDirectories(teamName) 83: unregisterTeamForSessionCleanup(teamName) 84: clearTeammateColors() 85: clearLeaderTeamName() 86: logEvent('tengu_team_deleted', { 87: team_name: 88: teamName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 89: }) 90: } 91: setAppState(prev => ({ 92: ...prev, 93: teamContext: undefined, 94: inbox: { 95: messages: [], 96: }, 97: })) 98: return { 99: data: { 100: success: true, 101: message: teamName 102: ? `Cleaned up directories and worktrees for team "${teamName}"` 103: : 'No team name found, nothing to clean up', 104: team_name: teamName, 105: }, 106: } 107: }, 108: renderToolUseMessage, 109: renderToolResultMessage, 110: } satisfies ToolDef<InputSchema, Output>)

File: src/tools/TeamDeleteTool/UI.tsx

typescript 1: import React from 'react'; 2: import { jsonParse } from '../../utils/slowOperations.js'; 3: import type { Output } from './TeamDeleteTool.js'; 4: export function renderToolUseMessage(_input: Record<string, unknown>): React.ReactNode { 5: return 'cleanup team: current'; 6: } 7: export function renderToolResultMessage(content: Output | string, _progressMessages: unknown, { 8: verbose: _verbose 9: }: { 10: verbose: boolean; 11: }): React.ReactNode { 12: const result: Output = typeof content === 'string' ? jsonParse(content) : content; 13: if ('success' in result && 'team_name' in result && 'message' in result) { 14: return null; 15: } 16: return null; 17: }

File: src/tools/testing/TestingPermissionTool.tsx

typescript 1: import { z } from 'zod/v4'; 2: import type { Tool } from '../../Tool.js'; 3: import { buildTool, type ToolDef } from '../../Tool.js'; 4: import { lazySchema } from '../../utils/lazySchema.js'; 5: const NAME = 'TestingPermission'; 6: const inputSchema = lazySchema(() => z.strictObject({})); 7: type InputSchema = ReturnType<typeof inputSchema>; 8: export const TestingPermissionTool: Tool<InputSchema, string> = buildTool({ 9: name: NAME, 10: maxResultSizeChars: 100_000, 11: async description() { 12: return 'Test tool that always asks for permission'; 13: }, 14: async prompt() { 15: return 'Test tool that always asks for permission before executing. Used for end-to-end testing.'; 16: }, 17: get inputSchema(): InputSchema { 18: return inputSchema(); 19: }, 20: userFacingName() { 21: return 'TestingPermission'; 22: }, 23: isEnabled() { 24: return "production" === 'test'; 25: }, 26: isConcurrencySafe() { 27: return true; 28: }, 29: isReadOnly() { 30: return true; 31: }, 32: async checkPermissions() { 33: return { 34: behavior: 'ask' as const, 35: message: `Run test?` 36: }; 37: }, 38: renderToolUseMessage() { 39: return null; 40: }, 41: renderToolUseProgressMessage() { 42: return null; 43: }, 44: renderToolUseQueuedMessage() { 45: return null; 46: }, 47: renderToolUseRejectedMessage() { 48: return null; 49: }, 50: renderToolResultMessage() { 51: return null; 52: }, 53: renderToolUseErrorMessage() { 54: return null; 55: }, 56: async call() { 57: return { 58: data: `${NAME} executed successfully` 59: }; 60: }, 61: mapToolResultToToolResultBlockParam(result, toolUseID) { 62: return { 63: type: 'tool_result', 64: content: String(result), 65: tool_use_id: toolUseID 66: }; 67: } 68: } satisfies ToolDef<InputSchema, string>);

File: src/tools/TodoWriteTool/constants.ts

typescript 1: export const TODO_WRITE_TOOL_NAME = 'TodoWrite'

File: src/tools/TodoWriteTool/prompt.ts

````typescript 1: import { FILE_EDIT_TOOL_NAME } from ‘../FileEditTool/constants.js’ 2: export const PROMPT = `Use this tool to create and manage a structured task list for your current coding session. This helps you track progress, organize complex tasks, and demonstrate thoroughness to the user. 3: It also helps the user understand the progress of the task and overall progress of their requests. 4: ## When to Use This Tool 5: Use this tool proactively in these scenarios: 6: 1. Complex multi-step tasks - When a task requires 3 or more distinct steps or actions 7: 2. Non-trivial and complex tasks - Tasks that require careful planning or multiple operations 8: 3. User explicitly requests todo list - When the user directly asks you to use the todo list 9: 4. User provides multiple tasks - When users provide a list of things to be done (numbered or comma-separated) 10: 5. After receiving new instructions - Immediately capture user requirements as todos 11: 6. When you start working on a task - Mark it as in_progress BEFORE beginning work. Ideally you should only have one todo as in_progress at a time 12: 7. After completing a task - Mark it as completed and add any new follow-up tasks discovered during implementation 13: ## When NOT to Use This Tool 14: Skip using this tool when: 15: 1. There is only a single, straightforward task 16: 2. The task is trivial and tracking it provides no organizational benefit 17: 3. The task can be completed in less than 3 trivial steps 18: 4. The task is purely conversational or informational 19: NOTE that you should not use this tool if there is only one trivial task to do. In this case you are better off just doing the task directly. 20: ## Examples of When to Use the Todo List 21: 22: User: I want to add a dark mode toggle to the application settings. Make sure you run the tests and build when you're done! 23: Assistant: *Creates todo list with the following items:* 24: 1. Creating dark mode toggle component in Settings page 25: 2. Adding dark mode state management (context/store) 26: 3. Implementing CSS-in-JS styles for dark theme 27: 4. Updating existing components to support theme switching 28: 5. Running tests and build process, addressing any failures or errors that occur 29: *Begins working on the first task* 30: 31: The assistant used the todo list because: 32: 1. Adding dark mode is a multi-step feature requiring UI, state management, and styling changes 33: 2. The user explicitly requested tests and build be run afterward 34: 3. The assistant inferred that tests and build need to pass by adding "Ensure tests and build succeed" as the final task 35: 36: 37: 38: User: Help me rename the function getCwd to getCurrentWorkingDirectory across my project 39: Assistant: *Uses grep or search tools to locate all instances of getCwd in the codebase* 40: I've found 15 instances of 'getCwd' across 8 different files. 41: *Creates todo list with specific items for each file that needs updating* 42: 43: The assistant used the todo list because: 44: 1. First, the assistant searched to understand the scope of the task 45: 2. Upon finding multiple occurrences across different files, it determined this was a complex task with multiple steps 46: 3. The todo list helps ensure every instance is tracked and updated systematically 47: 4. This approach prevents missing any occurrences and maintains code consistency 48: 49: 50: 51: User: I need to implement these features for my e-commerce site: user registration, product catalog, shopping cart, and checkout flow. 52: Assistant: *Creates a todo list breaking down each feature into specific tasks based on the project architecture* 53: Let's start with user registration. This will involve creating database models, API endpoints, and frontend forms that integrate with your existing authentication system. 54: 55: The assistant used the todo list because: 56: 1. The user provided multiple complex features to implement in a comma separated list 57: 2. The todo list helps organize these large features into manageable tasks 58: 3. This approach allows for tracking progress across the entire implementation 59: 60: 61: 62: User: Can you help optimize my React application? It's rendering slowly and has performance issues. 63: Assistant: *Reviews component structure, render patterns, state management, and data fetching* 64: After analyzing your codebase, I've identified several performance issues. 65: *Creates todo list with items like: 1) Implementing memoization for expensive calculations in ProductList, 2) Adding virtualization for long lists in Dashboard, 3) Optimizing image loading in Gallery component, 4) Fixing state update loops in ShoppingCart, 5) Reviewing bundle size and implementing code splitting* 66: Let's start by implementing memoization for the expensive calculations in your ProductList component. 67: 68: The assistant used the todo list because: 69: 1. First, the assistant examined the codebase to identify specific performance issues