Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion scripts/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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>
)
}
102 changes: 102 additions & 0 deletions src/tasks/MonitorMcpTask/MonitorMcpTask.ts
Original file line number Diff line number Diff line change
@@ -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)
}
Loading
Loading