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:
committed by
GitHub
parent
24d485f42f
commit
b818dd5958
@@ -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,
|
||||
|
||||
@@ -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'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>
|
||||
)
|
||||
}
|
||||
102
src/tasks/MonitorMcpTask/MonitorMcpTask.ts
Normal file
102
src/tasks/MonitorMcpTask/MonitorMcpTask.ts
Normal 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)
|
||||
}
|
||||
195
src/tools/MonitorTool/MonitorTool.ts
Normal file
195
src/tools/MonitorTool/MonitorTool.ts
Normal 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>)
|
||||
Reference in New Issue
Block a user