feat: implement Monitor tool for streaming shell output (#649)

* feat: implement Monitor tool for streaming shell output

Add the Monitor tool that executes shell commands in the background and
streams stdout line-by-line as notifications to the model. This enables
real-time monitoring of logs, builds, and long-running processes.

Implementation:
- MonitorTool (src/tools/MonitorTool/) — spawns LocalShellTask with
  kind='monitor', returns immediately with task ID
- MonitorMcpTask (src/tasks/MonitorMcpTask/) — task lifecycle management
  and agent cleanup via killMonitorMcpTasksForAgent()
- MonitorPermissionRequest — permission dialog component

The codebase already had all integration points wired (tools.ts, tasks.ts,
PermissionRequest.tsx, LocalShellTask kind='monitor', BashTool prompt).
This PR provides the missing implementations.

* fix: command-specific permission rule + architecture docs

- MonitorPermissionRequest: "don't ask again" now creates a
  command-prefix rule (like BashTool) instead of a blanket
  tool-name-only rule that would auto-allow all Monitor commands
- MonitorMcpTask: clarify architecture comments explaining why
  monitor_mcp type exists as a registry stub while actual tasks
  are local_bash with kind='monitor'

* fix: address Copilot review feedback

- Fix permission rule field: expression → ruleContent (Copilot #1)
- Handle empty command prefix: skip rule creation (Copilot #2)
- Remove unused useTheme() import (Copilot #3)
- Save permission rules under 'Bash' toolName so bashToolHasPermission
  can match them — Monitor delegates to Bash permission system (Copilot #4)
- Remove unused logError import from MonitorMcpTask (Copilot #6)
- Copilot #5 (getAppState throws): same pattern as BashTool:915, not a bug
This commit is contained in:
Nourrisse Florian
2026-04-13 15:39:07 +02:00
committed by GitHub
parent 24d485f42f
commit b818dd5958
4 changed files with 471 additions and 1 deletions

View File

@@ -25,7 +25,7 @@ const featureFlags: Record<string, boolean> = {
BRIDGE_MODE: false,
DAEMON: false,
AGENT_TRIGGERS: false,
MONITOR_TOOL: false,
MONITOR_TOOL: true,
ABLATION_BASELINE: false,
DUMP_SYSTEM_PROMPT: false,
CACHED_MICROCOMPACT: false,

View File

@@ -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<OptionValue>[] = [
{
label: 'Yes',
value: 'yes',
feedbackConfig: { type: 'accept' },
},
]
if (showAlwaysAllow) {
options.push({
label: (
<Text>
Yes, and don&apos;t ask again for{' '}
<Text bold>Monitor</Text> commands in{' '}
<Text bold>{originalCwd}</Text>
</Text>
),
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 (
<PermissionDialog title="Monitor" workerBadge={workerBadge}>
<Box flexDirection="column" paddingX={2} paddingY={1}>
<Text>
Monitor({command ?? ''})
</Text>
{description ? (
<Text dimColor>{description}</Text>
) : null}
</Box>
<Box flexDirection="column">
<PermissionRuleExplanation
permissionResult={toolUseConfirm.permissionResult}
toolType="tool"
/>
<PermissionPrompt
options={options}
onSelect={handleSelect}
onCancel={handleCancel}
toolAnalyticsContext={toolAnalyticsContext}
/>
</Box>
</PermissionDialog>
)
}

View File

@@ -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<MonitorMcpTaskState>(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)
}

View File

@@ -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<typeof inputSchema>
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<typeof outputSchema>
type Output = z.infer<OutputSchema>
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<z.infer<InputSchema>>,
): 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<InputSchema, Output>)