流出したclaude codeのソースコード5
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} </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'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> {progressData.taskDescription}</Text>}
303: <Text>
304: 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