diff --git a/scripts/build.ts b/scripts/build.ts index 39ee9361..8d46c314 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -26,11 +26,9 @@ const featureFlags: Record = { BRIDGE_MODE: false, // Remote desktop bridge via CCR infrastructure DAEMON: false, // Background daemon process (stubbed in open build) AGENT_TRIGGERS: false, // Scheduled remote agent triggers - MONITOR_TOOL: false, // MCP server monitoring/streaming tool ABLATION_BASELINE: false, // A/B testing harness for eval experiments CONTEXT_COLLAPSE: false, // Context collapsing optimization (stubbed) COMMIT_ATTRIBUTION: false, // Co-Authored-By metadata in git commits - TEAMMEM: false, // Team memory management UDS_INBOX: false, // Unix Domain Socket inter-session messaging BG_SESSIONS: false, // Background sessions via tmux (stubbed) WEB_BROWSER_TOOL: false, // Built-in browser automation (source not mirrored) @@ -41,13 +39,15 @@ const featureFlags: Record = { COORDINATOR_MODE: true, // Multi-agent coordinator with worker delegation BUILTIN_EXPLORE_PLAN_AGENTS: true, // Built-in Explore/Plan specialized subagents BUDDY: true, // Buddy mode for paired programming + MONITOR_TOOL: true, // MCP server monitoring/streaming tool + TEAMMEM: true, // Team memory management + MESSAGE_ACTIONS: true, // Message action buttons in the UI // ── Enabled: new activations ──────────────────────────────────────── DUMP_SYSTEM_PROMPT: true, // --dump-system-prompt CLI flag for debugging CACHED_MICROCOMPACT: true, // Cache-aware tool result truncation optimization AWAY_SUMMARY: true, // "While you were away" recap after 5min blur TRANSCRIPT_CLASSIFIER: true, // Auto-approval classifier for safe tool uses - MESSAGE_ACTIONS: true, // Message action buttons in the UI ULTRATHINK: true, // Deep thinking mode — type "ultrathink" to boost reasoning TOKEN_BUDGET: true, // Token budget tracking with usage warnings HISTORY_PICKER: true, // Enhanced interactive prompt history picker diff --git a/scripts/no-telemetry-growthbook-stub.test.ts b/scripts/no-telemetry-growthbook-stub.test.ts new file mode 100644 index 00000000..da0d33e1 --- /dev/null +++ b/scripts/no-telemetry-growthbook-stub.test.ts @@ -0,0 +1,146 @@ +import { afterAll, beforeEach, describe, expect, test } from 'bun:test' +import { mkdirSync, readFileSync, rmSync, unlinkSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' + +// --------------------------------------------------------------------------- +// Setup: extract the growthbook stub from no-telemetry-plugin.ts, write it to +// a temp .mjs file, and dynamically import it so we can test the real code +// that gets bundled. +// --------------------------------------------------------------------------- + +const pluginSource = readFileSync(join(__dirname, 'no-telemetry-plugin.ts'), 'utf-8') +const stubMatch = pluginSource.match(/'services\/analytics\/growthbook': `([\s\S]*?)`/) +if (!stubMatch) throw new Error('Could not extract growthbook stub from no-telemetry-plugin.ts') + +const testDir = join(tmpdir(), `growthbook-stub-test-${process.pid}`) +const stubFile = join(testDir, 'growthbook-stub.mjs') +const flagsFile = join(testDir, 'test-flags.json') + +mkdirSync(testDir, { recursive: true }) +writeFileSync(stubFile, stubMatch[1]) + +// Point the stub at our test flags file (checked by _loadFlags on first access) +process.env.CLAUDE_FEATURE_FLAGS_FILE = flagsFile + +const stub = await import(stubFile) + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('growthbook stub — local feature flag overrides', () => { + beforeEach(() => { + stub.resetGrowthBook() + try { unlinkSync(flagsFile) } catch { /* may not exist */ } + }) + + afterAll(() => { + rmSync(testDir, { recursive: true, force: true }) + delete process.env.CLAUDE_FEATURE_FLAGS_FILE + }) + + // ── File absent ────────────────────────────────────────────────── + + test('returns defaultValue when flags file is absent', () => { + expect(stub.getFeatureValue_CACHED_MAY_BE_STALE('tengu_foo', 42)).toBe(42) + }) + + test('getAllGrowthBookFeatures returns {} when file is absent', () => { + expect(stub.getAllGrowthBookFeatures()).toEqual({}) + }) + + // ── Valid JSON object ──────────────────────────────────────────── + + test('loads and returns values from a valid JSON file', () => { + writeFileSync(flagsFile, JSON.stringify({ tengu_foo: true, tengu_bar: 'hello' })) + + expect(stub.getFeatureValue_CACHED_MAY_BE_STALE('tengu_foo', false)).toBe(true) + expect(stub.getFeatureValue_CACHED_MAY_BE_STALE('tengu_bar', 'default')).toBe('hello') + }) + + test('returns defaultValue for keys not present in the file', () => { + writeFileSync(flagsFile, JSON.stringify({ tengu_foo: true })) + + expect(stub.getFeatureValue_CACHED_MAY_BE_STALE('tengu_missing', 99)).toBe(99) + }) + + test('getAllGrowthBookFeatures returns the full flags object', () => { + const flags = { tengu_a: true, tengu_b: false, tengu_c: 42 } + writeFileSync(flagsFile, JSON.stringify(flags)) + + expect(stub.getAllGrowthBookFeatures()).toEqual(flags) + }) + + // ── Malformed / non-object JSON ────────────────────────────────── + + test('falls back to defaults on malformed JSON', () => { + writeFileSync(flagsFile, '{not valid json!!!') + + expect(stub.getFeatureValue_CACHED_MAY_BE_STALE('tengu_foo', 'fallback')).toBe('fallback') + }) + + test('falls back to defaults when JSON is a primitive (true)', () => { + writeFileSync(flagsFile, 'true') + + expect(stub.getFeatureValue_CACHED_MAY_BE_STALE('tengu_foo', 'fallback')).toBe('fallback') + }) + + test('falls back to defaults when JSON is an array', () => { + writeFileSync(flagsFile, '["a", "b"]') + + expect(stub.getFeatureValue_CACHED_MAY_BE_STALE('tengu_foo', 'fallback')).toBe('fallback') + }) + + // ── Cache invalidation ─────────────────────────────────────────── + + test('resetGrowthBook clears cache so the file is re-read', () => { + writeFileSync(flagsFile, JSON.stringify({ tengu_foo: 'first' })) + expect(stub.getFeatureValue_CACHED_MAY_BE_STALE('tengu_foo', 'x')).toBe('first') + + // Update the file — cached value is still 'first' + writeFileSync(flagsFile, JSON.stringify({ tengu_foo: 'second' })) + expect(stub.getFeatureValue_CACHED_MAY_BE_STALE('tengu_foo', 'x')).toBe('first') + + // After reset, the new value is picked up + stub.resetGrowthBook() + expect(stub.getFeatureValue_CACHED_MAY_BE_STALE('tengu_foo', 'x')).toBe('second') + }) + + test('refreshGrowthBookFeatures clears cache', async () => { + writeFileSync(flagsFile, JSON.stringify({ tengu_foo: 'v1' })) + expect(stub.getFeatureValue_CACHED_MAY_BE_STALE('tengu_foo', 'x')).toBe('v1') + + writeFileSync(flagsFile, JSON.stringify({ tengu_foo: 'v2' })) + await stub.refreshGrowthBookFeatures() + expect(stub.getFeatureValue_CACHED_MAY_BE_STALE('tengu_foo', 'x')).toBe('v2') + }) + + // ── Multiple getter variants ───────────────────────────────────── + + test('all getter functions read from local flags', async () => { + writeFileSync(flagsFile, JSON.stringify({ tengu_gate: true, tengu_config: { a: 1 } })) + + expect(await stub.getFeatureValue_DEPRECATED('tengu_gate', false)).toBe(true) + stub.resetGrowthBook() + expect(stub.getFeatureValue_CACHED_WITH_REFRESH('tengu_gate', false)).toBe(true) + stub.resetGrowthBook() + expect(stub.checkStatsigFeatureGate_CACHED_MAY_BE_STALE('tengu_gate')).toBe(true) + stub.resetGrowthBook() + expect(await stub.checkGate_CACHED_OR_BLOCKING('tengu_gate')).toBe(true) + stub.resetGrowthBook() + expect(await stub.getDynamicConfig_BLOCKS_ON_INIT('tengu_config', {})).toEqual({ a: 1 }) + stub.resetGrowthBook() + expect(stub.getDynamicConfig_CACHED_MAY_BE_STALE('tengu_config', {})).toEqual({ a: 1 }) + }) + + // ── Security gate ──────────────────────────────────────────────── + + test('checkSecurityRestrictionGate always returns false regardless of flags', async () => { + writeFileSync(flagsFile, JSON.stringify({ + tengu_disable_bypass_permissions_mode: true, + })) + + expect(await stub.checkSecurityRestrictionGate()).toBe(false) + }) +}) diff --git a/scripts/no-telemetry-plugin.ts b/scripts/no-telemetry-plugin.ts index c0ad74d8..97fb54a3 100644 --- a/scripts/no-telemetry-plugin.ts +++ b/scripts/no-telemetry-plugin.ts @@ -34,28 +34,55 @@ export function _resetForTesting() {} `, 'services/analytics/growthbook': ` +import _fs from 'node:fs'; +import _path from 'node:path'; +import _os from 'node:os'; + +let _flags = undefined; + +function _loadFlags() { + if (_flags !== undefined) return; + try { + const flagsPath = process.env.CLAUDE_FEATURE_FLAGS_FILE + || _path.join(_os.homedir(), '.claude', 'feature-flags.json'); + const parsed = JSON.parse(_fs.readFileSync(flagsPath, 'utf-8')); + _flags = (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) ? parsed : null; + } catch { + _flags = null; + } +} + +function _getFlagValue(key, defaultValue) { + _loadFlags(); + if (_flags != null && Object.hasOwn(_flags, key)) return _flags[key]; + return defaultValue; +} + const noop = () => {}; export function onGrowthBookRefresh() { return noop; } export function hasGrowthBookEnvOverride() { return false; } -export function getAllGrowthBookFeatures() { return {}; } +export function getAllGrowthBookFeatures() { _loadFlags(); return _flags || {}; } export function getGrowthBookConfigOverrides() { return {}; } export function setGrowthBookConfigOverride() {} export function clearGrowthBookConfigOverrides() {} export function getApiBaseUrlHost() { return undefined; } export const initializeGrowthBook = async () => null; -export async function getFeatureValue_DEPRECATED(feature, defaultValue) { return defaultValue; } -export function getFeatureValue_CACHED_MAY_BE_STALE(feature, defaultValue) { return defaultValue; } -export function getFeatureValue_CACHED_WITH_REFRESH(feature, defaultValue) { return defaultValue; } -export function checkStatsigFeatureGate_CACHED_MAY_BE_STALE() { return false; } -export async function checkSecurityRestrictionGate() { return false; } -export async function checkGate_CACHED_OR_BLOCKING() { return false; } +export async function getFeatureValue_DEPRECATED(feature, defaultValue) { return _getFlagValue(feature, defaultValue); } +export function getFeatureValue_CACHED_MAY_BE_STALE(feature, defaultValue) { return _getFlagValue(feature, defaultValue); } +export function getFeatureValue_CACHED_WITH_REFRESH(feature, defaultValue) { return _getFlagValue(feature, defaultValue); } +export function checkStatsigFeatureGate_CACHED_MAY_BE_STALE(gate) { return Boolean(_getFlagValue(gate, false)); } +// Security killswitch — always false in the open build. Anthropic uses this +// gate to remotely disable bypassPermissions mode; exposing it via local flags +// would let users accidentally lock themselves out of --dangerously-skip-permissions. +export async function checkSecurityRestrictionGate(gate) { return false; } +export async function checkGate_CACHED_OR_BLOCKING(gate) { return Boolean(_getFlagValue(gate, false)); } export function refreshGrowthBookAfterAuthChange() {} -export function resetGrowthBook() {} -export async function refreshGrowthBookFeatures() {} +export function resetGrowthBook() { _flags = undefined; } +export async function refreshGrowthBookFeatures() { _flags = undefined; } export function setupPeriodicGrowthBookRefresh() {} export function stopPeriodicGrowthBookRefresh() {} -export async function getDynamicConfig_BLOCKS_ON_INIT(configName, defaultValue) { return defaultValue; } -export function getDynamicConfig_CACHED_MAY_BE_STALE(configName, defaultValue) { return defaultValue; } +export async function getDynamicConfig_BLOCKS_ON_INIT(configName, defaultValue) { return _getFlagValue(configName, defaultValue); } +export function getDynamicConfig_CACHED_MAY_BE_STALE(configName, defaultValue) { return _getFlagValue(configName, defaultValue); } `, 'services/analytics/sink': ` diff --git a/src/components/permissions/MonitorPermissionRequest/MonitorPermissionRequest.tsx b/src/components/permissions/MonitorPermissionRequest/MonitorPermissionRequest.tsx new file mode 100644 index 00000000..eb8243d0 --- /dev/null +++ b/src/components/permissions/MonitorPermissionRequest/MonitorPermissionRequest.tsx @@ -0,0 +1,173 @@ +import React from 'react' +import { getOriginalCwd } from '../../../bootstrap/state.js' +import { Box, Text } from '../../../ink.js' +import { sanitizeToolNameForAnalytics } from '../../../services/analytics/metadata.js' +import { env } from '../../../utils/env.js' +import { shouldShowAlwaysAllowOptions } from '../../../utils/permissions/permissionsLoader.js' +import { usePermissionRequestLogging } from '../hooks.js' +import { PermissionDialog } from '../PermissionDialog.js' +import { + PermissionPrompt, + type PermissionPromptOption, +} from '../PermissionPrompt.js' +import type { PermissionRequestProps } from '../PermissionRequest.js' +import { PermissionRuleExplanation } from '../PermissionRuleExplanation.js' +import { logUnaryPermissionEvent } from '../utils.js' + +type OptionValue = 'yes' | 'yes-dont-ask-again' | 'no' + +export function MonitorPermissionRequest({ + toolUseConfirm, + onDone, + onReject, + workerBadge, +}: PermissionRequestProps) { + const { command, description } = toolUseConfirm.input as { + command?: string + description?: string + } + + usePermissionRequestLogging(toolUseConfirm, { + completion_type: 'tool_use_single', + language_name: 'none', + }) + + const handleSelect = ( + value: OptionValue, + feedback?: string, + ) => { + switch (value) { + case 'yes': { + logUnaryPermissionEvent({ + completion_type: 'tool_use_single', + event: 'accept', + metadata: { + language_name: 'none', + message_id: toolUseConfirm.assistantMessage.message.id, + platform: env.platform, + }, + }) + toolUseConfirm.onAllow(toolUseConfirm.input, [], feedback) + onDone() + break + } + case 'yes-dont-ask-again': { + logUnaryPermissionEvent({ + completion_type: 'tool_use_single', + event: 'accept', + metadata: { + language_name: 'none', + message_id: toolUseConfirm.assistantMessage.message.id, + platform: env.platform, + }, + }) + // Save the rule under 'Bash' toolName because checkPermissions + // delegates to bashToolHasPermission which matches rules against + // BashTool. Using 'Monitor' here would create a rule that's never + // checked. Command-specific prefix (like BashTool's shellRuleMatching). + const cmdForRule = command?.trim() || '' + const prefix = cmdForRule.split(/\s+/).slice(0, 2).join(' ') + toolUseConfirm.onAllow(toolUseConfirm.input, prefix ? [ + { + type: 'addRules', + rules: [{ toolName: 'Bash', ruleContent: `${prefix}:*` }], + behavior: 'allow', + destination: 'localSettings', + }, + ] : []) + onDone() + break + } + case 'no': { + logUnaryPermissionEvent({ + completion_type: 'tool_use_single', + event: 'reject', + metadata: { + language_name: 'none', + message_id: toolUseConfirm.assistantMessage.message.id, + platform: env.platform, + }, + }) + toolUseConfirm.onReject(feedback) + onReject() + onDone() + break + } + } + } + + const handleCancel = () => { + logUnaryPermissionEvent({ + completion_type: 'tool_use_single', + event: 'reject', + metadata: { + language_name: 'none', + message_id: toolUseConfirm.assistantMessage.message.id, + platform: env.platform, + }, + }) + toolUseConfirm.onReject() + onReject() + onDone() + } + + const showAlwaysAllow = shouldShowAlwaysAllowOptions() + const originalCwd = getOriginalCwd() + + const options: PermissionPromptOption[] = [ + { + label: 'Yes', + value: 'yes', + feedbackConfig: { type: 'accept' }, + }, + ] + + if (showAlwaysAllow) { + options.push({ + label: ( + + Yes, and don't ask again for{' '} + Monitor commands in{' '} + {originalCwd} + + ), + value: 'yes-dont-ask-again', + }) + } + + options.push({ + label: 'No', + value: 'no', + feedbackConfig: { type: 'reject' }, + }) + + const toolAnalyticsContext = { + toolName: sanitizeToolNameForAnalytics(toolUseConfirm.tool.name), + isMcp: toolUseConfirm.tool.isMcp ?? false, + } + + return ( + + + + Monitor({command ?? ''}) + + {description ? ( + {description} + ) : null} + + + + + + + ) +} diff --git a/src/memdir/teamMemPaths.ts b/src/memdir/teamMemPaths.ts index 1a13ae7e..13a6ed87 100644 --- a/src/memdir/teamMemPaths.ts +++ b/src/memdir/teamMemPaths.ts @@ -74,7 +74,7 @@ export function isTeamMemoryEnabled(): boolean { if (!isAutoMemoryEnabled()) { return false } - return getFeatureValue_CACHED_MAY_BE_STALE('tengu_herring_clock', false) + return getFeatureValue_CACHED_MAY_BE_STALE('tengu_herring_clock', true) } /** diff --git a/src/tasks/MonitorMcpTask/MonitorMcpTask.ts b/src/tasks/MonitorMcpTask/MonitorMcpTask.ts new file mode 100644 index 00000000..fb789a93 --- /dev/null +++ b/src/tasks/MonitorMcpTask/MonitorMcpTask.ts @@ -0,0 +1,102 @@ +// MonitorMcpTask — task registry entry for the 'monitor_mcp' type. +// +// Architecture: MonitorTool spawns shell processes as LocalShellTask +// (type: 'local_bash', kind: 'monitor'). The 'monitor_mcp' type exists +// in TaskType for forward-compatibility with MCP-based monitoring (not +// yet implemented). This module satisfies the import from tasks.ts and +// provides killMonitorMcpTasksForAgent for agent-scoped cleanup of +// monitor-kind shell tasks. + +import type { AppState } from '../../state/AppState.js' +import type { SetAppState, Task, TaskStateBase } from '../../Task.js' +import type { AgentId } from '../../types/ids.js' +import { logForDebugging } from '../../utils/debug.js' +import { dequeueAllMatching } from '../../utils/messageQueueManager.js' +import { evictTaskOutput } from '../../utils/task/diskOutput.js' +import { updateTaskState } from '../../utils/task/framework.js' +import { isLocalShellTask } from '../LocalShellTask/guards.js' +import { killTask } from '../LocalShellTask/killShellTasks.js' + +export type MonitorMcpTaskState = TaskStateBase & { + type: 'monitor_mcp' + agentId?: AgentId +} + +function isMonitorMcpTask(task: unknown): task is MonitorMcpTaskState { + return ( + typeof task === 'object' && + task !== null && + 'type' in task && + task.type === 'monitor_mcp' + ) +} + +export const MonitorMcpTask: Task = { + name: 'MonitorMcpTask', + type: 'monitor_mcp', + async kill(taskId, setAppState) { + updateTaskState(taskId, setAppState, task => { + if (task.status !== 'running') { + return task + } + + return { + ...task, + status: 'killed', + notified: true, + endTime: Date.now(), + } + }) + void evictTaskOutput(taskId) + }, +} + +/** + * Kill all monitor tasks owned by a given agent. + * + * MonitorTool spawns tasks as local_bash with kind='monitor'. When an agent + * exits, killShellTasksForAgent already handles those. This function provides + * additional cleanup for any monitor_mcp-typed tasks and also kills any + * local_bash tasks with kind='monitor' that might have been missed (belt and + * suspenders). Finally, it purges queued notifications for the dead agent. + */ +export function killMonitorMcpTasksForAgent( + agentId: AgentId, + getAppState: () => AppState, + setAppState: SetAppState, +): void { + const tasks = getAppState().tasks ?? {} + + for (const [taskId, task] of Object.entries(tasks)) { + // Kill monitor_mcp tasks for this agent + if ( + isMonitorMcpTask(task) && + task.agentId === agentId && + task.status === 'running' + ) { + logForDebugging( + `killMonitorMcpTasksForAgent: killing monitor_mcp task ${taskId} (agent ${agentId} exiting)`, + ) + void MonitorMcpTask.kill(taskId, setAppState) + } + + // Also kill local_bash tasks with kind='monitor' for this agent + // (killShellTasksForAgent already does this, but being explicit + // guards against ordering issues) + if ( + isLocalShellTask(task) && + task.kind === 'monitor' && + task.agentId === agentId && + task.status === 'running' + ) { + logForDebugging( + `killMonitorMcpTasksForAgent: killing monitor shell task ${taskId} (agent ${agentId} exiting)`, + ) + killTask(taskId, setAppState) + } + } + + // Purge any queued notifications addressed to this agent — its query loop + // has exited and won't drain them. + dequeueAllMatching(cmd => cmd.agentId === agentId) +} diff --git a/src/tools/MonitorTool/MonitorTool.ts b/src/tools/MonitorTool/MonitorTool.ts new file mode 100644 index 00000000..0dbb0f82 --- /dev/null +++ b/src/tools/MonitorTool/MonitorTool.ts @@ -0,0 +1,195 @@ +import type { ToolResultBlockParam } from '@anthropic-ai/sdk/resources/index.mjs' +import React from 'react' +import { z } from 'zod/v4' +import { buildTool, type ToolDef } from '../../Tool.js' +import { spawnShellTask } from '../../tasks/LocalShellTask/LocalShellTask.js' +import { lazySchema } from '../../utils/lazySchema.js' +import { exec } from '../../utils/Shell.js' +import { getTaskOutputPath } from '../../utils/task/diskOutput.js' +import { + bashToolHasPermission, + matchWildcardPattern, + permissionRuleExtractPrefix, +} from '../BashTool/bashPermissions.js' +import { parseForSecurity } from '../../utils/bash/ast.js' + +export const MONITOR_TOOL_NAME = 'Monitor' + +const MONITOR_TIMEOUT_MS = 30 * 60 * 1000 // 30 minutes + +const inputSchema = lazySchema(() => + z.strictObject({ + command: z + .string() + .describe('The shell command to run and monitor'), + description: z + .string() + .describe( + 'Clear, concise description of what this command does in active voice.', + ), + }), +) +type InputSchema = ReturnType + +const outputSchema = lazySchema(() => + z.object({ + taskId: z + .string() + .describe('The ID of the background monitor task'), + outputFile: z + .string() + .describe('Path to the file where output is being written'), + }), +) +type OutputSchema = ReturnType + +type Output = z.infer + +export const MonitorTool = buildTool({ + name: MONITOR_TOOL_NAME, + searchHint: 'stream shell output as notifications', + maxResultSizeChars: 10_000, + strict: true, + + isConcurrencySafe() { + return true + }, + + toAutoClassifierInput(input) { + return input.command + }, + + async preparePermissionMatcher({ command }) { + const parsed = await parseForSecurity(command) + if (parsed.kind !== 'simple') { + return () => true + } + const subcommands = parsed.commands.map(c => c.argv.join(' ')) + return (pattern: string) => { + const prefix = permissionRuleExtractPrefix(pattern) + return subcommands.some(cmd => { + if (prefix !== null) { + return cmd === prefix || cmd.startsWith(`${prefix} `) + } + return matchWildcardPattern(pattern, cmd) + }) + } + }, + + async checkPermissions(input, context) { + // Delegate to the bash permission system — Monitor runs shell commands + // just like Bash does, so the same permission rules apply. + return bashToolHasPermission({ command: input.command }, context) + }, + + async description(input) { + return input.description || 'Monitor shell command' + }, + + async prompt() { + return `Execute a shell command in the background and stream its stdout line-by-line as notifications. Each polling interval (~1s), new output lines are delivered to you. Use this for monitoring logs, watching build output, or observing long-running processes. For one-shot "wait until done" commands, prefer Bash with run_in_background instead.` + }, + + get inputSchema(): InputSchema { + return inputSchema() + }, + + get outputSchema(): OutputSchema { + return outputSchema() + }, + + userFacingName() { + return 'Monitor' + }, + + getToolUseSummary(input) { + if (!input?.description) { + return input?.command ?? null + } + return input.description + }, + + getActivityDescription(input) { + if (!input?.description) { + return 'Starting monitor' + } + return `Monitoring ${input.description}` + }, + + renderToolUseMessage( + input: Partial>, + ): React.ReactNode { + const cmd = input.command ?? '' + const desc = input.description ?? '' + if (desc && cmd) { + return `${desc}: ${cmd}` + } + return cmd || desc || '' + }, + + renderToolResultMessage( + output: Output, + ): React.ReactNode { + return `Monitor started (task ${output.taskId})` + }, + + mapToolResultToToolResultBlockParam( + output: Output, + toolUseID: string, + ): ToolResultBlockParam { + const outputPath = output.outputFile + return { + tool_use_id: toolUseID, + type: 'tool_result', + content: `Monitor task started with ID: ${output.taskId}. Output is being streamed to: ${outputPath}. You will receive notifications as new output lines appear (~1s polling). Use TaskStop to end monitoring when done.`, + } + }, + + async call(input, toolUseContext) { + const { command, description } = input + const { abortController, setAppState } = toolUseContext + + // Create the shell command — uses the same Shell.exec() as BashTool. + // This is intentionally a shell execution (not execFile) because + // MonitorTool needs full shell features (pipes, redirects, etc.) + // just like BashTool does. + const shellCommand = await exec( + command, + abortController.signal, + 'bash', + { timeout: MONITOR_TIMEOUT_MS }, + ) + + // Spawn as a background task with kind='monitor' — identical to + // BashTool's run_in_background path but always monitor-flavored. + const handle = await spawnShellTask( + { + command, + description: description || command, + shellCommand, + toolUseId: toolUseContext.toolUseId, + agentId: toolUseContext.agentId, + kind: 'monitor', + }, + { + abortController, + getAppState: () => { + throw new Error( + 'getAppState not available in MonitorTool spawn context', + ) + }, + setAppState: toolUseContext.setAppStateForTasks ?? setAppState, + }, + ) + + const taskId = handle.taskId + const outputFile = getTaskOutputPath(taskId) + + return { + data: { + taskId, + outputFile, + }, + } + }, +} satisfies ToolDef)