( tool: T, input: z.infer<T['inputSchema']>, agentId?: AgentId, )
| 575 | |
| 576 | // TODO: Generalize this to all tools |
| 577 | export function normalizeToolInput<T extends Tool>( |
| 578 | tool: T, |
| 579 | input: z.infer<T['inputSchema']>, |
| 580 | agentId?: AgentId, |
| 581 | ): z.infer<T['inputSchema']> { |
| 582 | switch (tool.name) { |
| 583 | case EXIT_PLAN_MODE_V2_TOOL_NAME: { |
| 584 | // Always inject plan content and file path for ExitPlanModeV2 so hooks/SDK get the plan. |
| 585 | // The V2 tool reads plan from file instead of input, but hooks/SDK |
| 586 | const plan = getPlan(agentId) |
| 587 | const planFilePath = getPlanFilePath(agentId) |
| 588 | // Persist file snapshot for CCR sessions so the plan survives pod recycling |
| 589 | void persistFileSnapshotIfRemote() |
| 590 | return plan !== null ? { ...input, plan, planFilePath } : input |
| 591 | } |
| 592 | case BashTool.name: { |
| 593 | // Validated upstream, won't throw |
| 594 | const parsed = BashTool.inputSchema.parse(input) |
| 595 | const { command, timeout, description } = parsed |
| 596 | const cwd = getCwd() |
| 597 | let normalizedCommand = command.replace(`cd ${cwd} && `, '') |
| 598 | if (getPlatform() === 'windows') { |
| 599 | normalizedCommand = normalizedCommand.replace( |
| 600 | `cd ${windowsPathToPosixPath(cwd)} && `, |
| 601 | '', |
| 602 | ) |
| 603 | } |
| 604 | |
| 605 | // Replace \\; with \; (commonly needed for find -exec commands) |
| 606 | normalizedCommand = normalizedCommand.replace(/\\\\;/g, '\\;') |
| 607 | |
| 608 | // Logging for commands that are only echoing a string. This is to help us understand how often Claude talks via bash |
| 609 | if (/^echo\s+["']?[^|&;><]*["']?$/i.test(normalizedCommand.trim())) { |
| 610 | logEvent('tengu_bash_tool_simple_echo', {}) |
| 611 | } |
| 612 | |
| 613 | // Check for run_in_background (may not exist in schema if CLAUDE_CODE_DISABLE_BACKGROUND_TASKS is set) |
| 614 | const run_in_background = |
| 615 | 'run_in_background' in parsed ? parsed.run_in_background : undefined |
| 616 | |
| 617 | // SAFETY: Cast is safe because input was validated by .parse() above. |
| 618 | // TypeScript can't narrow the generic T based on switch(tool.name), so it |
| 619 | // doesn't know the return type matches T['inputSchema']. This is a fundamental |
| 620 | // TS limitation with generics, not bypassable without major refactoring. |
| 621 | return { |
| 622 | command: normalizedCommand, |
| 623 | description, |
| 624 | ...(timeout !== undefined && { timeout }), |
| 625 | ...(description !== undefined && { description }), |
| 626 | ...(run_in_background !== undefined && { run_in_background }), |
| 627 | ...('dangerouslyDisableSandbox' in parsed && |
| 628 | parsed.dangerouslyDisableSandbox !== undefined && { |
| 629 | dangerouslyDisableSandbox: parsed.dangerouslyDisableSandbox, |
| 630 | }), |
| 631 | } as z.infer<T['inputSchema']> |
| 632 | } |
| 633 | case FileEditTool.name: { |
| 634 | // Validated upstream, won't throw |
no test coverage detected